26331 Commits

Author SHA1 Message Date
Zachary Lowden 24a0240db6 fix(stripe): stop fractional Buzz amounts reaching Stripe as 500s (#4952)
* fix(stripe): reject fractional Buzz amounts at the trust boundary, not as a 500

Stripe amounts are in the currency's minor unit and must be whole. The Buzz
purchase form derives USD cents by dividing the Buzz amount by 10, so any
free-typed Buzz amount that is not a multiple of 10 (10,004) produced 1000.4
cents. Stripe answered `Invalid integer: 1000.4`, a raw throw out of
paymentIntents.create that reached the client as a tRPC INTERNAL_SERVER_ERROR.

Three changes, all on the same route:

- `paymentIntentCreationSchema.unitAmount` gains `.int()`. The tRPC input schema
  is the trust boundary, so a fractional amount is now a BAD_REQUEST naming the
  field rather than a 500 naming nothing.
- The form ceils the derived cents value, so a legitimate purchase cannot
  produce the fraction in the first place — without this the schema would turn
  the 500 into a 400 for a purchase that ought to succeed. Ceil rather than
  round, matching the minBuzzAmount derivation beside it and never granting
  more Buzz than is charged for. The submitted Buzz amount is re-derived from
  this value, so the pair stays consistent with the server's
  `unitAmount === buzzAmount / 10` check.
- The amount-tamper guard throws a typed BAD_REQUEST instead of a bare `Error`.
  `getTRPCErrorFromUnknown` maps a plain Error to INTERNAL_SERVER_ERROR, so
  rejected input on this route also answered with a 500. The condition is
  unchanged; only its type.

Regression matrix, both files watched red before the change:

  at origin/main (d7038c5aa8):  Test Files 2 failed (2) | Tests 3 failed | 8 passed (11)
  at HEAD:                      Test Files 2 passed (2) | Tests 11 passed (11)

The 8 that pass while red are deliberate controls: the whole-amount accept, and
the min/max bounds the integer rule must not have replaced.

Not covered by a test: the form's `Math.ceil`. It is a UX fix rather than a
safety one — the schema above is what actually stops a fraction reaching
Stripe — and a rendering test for this component was judged out of proportion
to that. Called out rather than implied.

Scope: the integer bound is applied to the Stripe route only. The same derived
`unitAmount` is handed to the Paddle and Coinbase buttons, whose schemas declare
the same min/max pair without `.int()`. Whether those providers reject a
fractional minor unit was not established, so they are deliberately unchanged.

* refactor(buzz): give the Buzz-to-cents ceil one home and a test

The `.int()` added in the previous commit covers the Stripe route only. The
form's ceil is the half that reaches every provider — Paddle, Coinbase and
EmerchantPay all accept the same derived `unitAmount` and none of their input
schemas declares an integer bound — and it was the half with no test.

Extract `buzzAmountToUnitAmount` (plus the `BUZZ_PER_USD_CENT` ratio it
divides by) into `src/shared/utils/buzz-charge.ts` and use it at both live
derivation sites in BuzzPurchaseImproved. Behaviour is unchanged at both: the
minBuzzAmount site already ceiled, and the free-typed field's ceil moves
verbatim into the helper.

The rule now has one home and six assertions. Mutation-checked rather than
assumed: Math.ceil -> Math.round kills 2 tests, dropping the rounding entirely
kills 4, and changing the ratio to 100 kills 5 — each on its own assertion.

`src/components/Buzz/BuzzPurchase.tsx` open-codes the same derivation twice
more and is deliberately left alone: it has zero importers repo-wide and is a
separate deletion candidate, so consolidating into it would be work on a file
slated to be removed.

* fix(stripe): address round-1 audit — restore the tamper counter, guard the call site

Round 1 of the adversarial audit produced five should-fix findings. Four were claims
the code made that the tree contradicts; one was a real coverage hole. All are in-band.

1. The purchase form's call site had NO guard, and the gap was measured, not inferred.
   Reverting `setCustomAmount(buzzAmountToUnitAmount(...))` to the pre-fix `/ 10` puts the
   production bug back verbatim and left the helper suite, the schema suite and both stripe
   service suites GREEN (17/17), plus three neighbouring repo guards. With `.int()` now on
   the schema that revert is worse than the original defect: instead of 500ing at Stripe,
   a non-multiple-of-ten Buzz amount is rejected at our own trust boundary, so Pay Now
   fails outright. Adds `no-open-coded-buzz-cents`, a source-text call-site ledger in the
   established `src/server/services/__tests__/no-*.test.ts` convention.

   Mutation-verified, each mutant killed by this guard's OWN named assertion, with a green
   control: the reverted call site (2 assertions red), the `/ BUZZ_PER_USD_CENT` spelling
   (2 red), dropping one of the two call sites (count ledger red), diverging the server
   guard's ratio to `/ 20` (relationship red), and `ceil` -> `round` (ceil check red).

2. `stripe.service.ts` — the amount-tamper guard lost its only counter when it was demoted
   500 -> 400. `recordTrpcError` increments `civitai_app_http_errors_total` only for
   `status >= 500`, and a 4xx is tagged `type:'info'`, so the guard now fires no metric and
   leaves the error stream. A scripted probe hunting for a bypass would be invisible. Adds
   a `type:'warning'` logToAxiom before the throw, matching the `buzz-purchase-currency`
   override pattern 30 lines below. Logged at warning because, unlike the fractional amount,
   a mismatched pair cannot be produced by the UI at all.

3. `payment-intent-unit-amount.schema.test.ts` — a committed docblock asserted the
   `createBuzzSession` path "is DELETED in this change" and "There is nothing left to
   bound." Both false: the path is live here and the deletion is #4955, still open. It read
   as coverage while providing none, over an exposed authenticated Stripe-calling procedure
   with an unbounded amount. Restated with what is actually true, and what to do if #4955
   is closed unmerged.

4. `stripe.schema.ts` — the scope comment said Paddle and Coinbase "declare the same
   min/max pair without `.int()`". Verified against the tree: Paddle does; Coinbase is a
   bare `z.number()` with no bound at all, and EmerchantPay was omitted entirely. Replaced
   with the measured per-provider table. Coinbase is a strictly wider hole than Stripe's
   was.

5. `buzz-charge.test.ts` — a test named "pins the Buzz-per-cent ratio the server tamper
   check re-derives" asserted a local constant against a literal and touched no server
   code, so it could neither detect nor locate a divergence. Renamed to what it does; the
   relationship it claimed is now pinned by the new ledger.

`CLAUDE.md`, `.claude/agents/civitai-test-review.md` and `test:lint-rules` updated for the
new guard — `no-lint-rules-script-drift` requires it and caught the omission.

Verified at the PR head + these fixes: 5 files / 34 tests green, no zero-test non-runs.
The audit's separate finding that the merged tree adds no type errors over origin/main
(25 errors, byte-identical on both sides, all from a stale generated Prisma client in a
shared node_modules) is an environment defect and is unchanged by this commit.

* fix(buzz): round-2 audit — anchor the guard on the wire value, not the call sites

Round 2 was a delta re-audit of round 1's fixes. It found four mutants that DEFEATED
round 1's new guard, plus two cases where that guard failed correct code. Every defect
below was introduced by round 1; none was in the original change.

1. THE GUARD ANCHORED ON THE WRONG THING. Round 1 keyed on `setCustomAmount(...)` call
   sites. Two one-line mutants put the production bug back and stayed green:
     - `setCustomAmount(Number(x) / 10)` — the `[^)]*` class cannot cross a `)`;
     - rewriting `const unitAmount = ...` (the value actually handed to
       `stripe.getPaymentIntent`) to divide `customBuzzAmount`, leaving BOTH helper call
       sites intact so the count ledger stayed satisfied.
   The call sites were never the seam. The guard now pins the wire-value expression
   verbatim and separately asserts the live form contains NO open-coded cents division in
   any shape. A guard enumerating call sites can be walked around by adding one more.

2. A NEW IMPORTER WAS INVISIBLE. `EXPECTED_IMPORTERS` filtered a one-element list against
   itself: no walk, and it could only ever shrink. A new module importing the helper AND
   open-coding `/ 10` passed. It now walks `src/` and asserts the exact set, so it fails
   when the set GROWS as well as shrinks. The dead legacy `BuzzPurchase.tsx` — which
   open-codes the derivation twice — has its deadness asserted rather than assumed.

3. THE RESTORED MONITORING SIGNAL HAD NO TEST. Deleting round 1's `logToAxiom` left the
   suite green. The sibling `buzz-purchase-currency` override is pinned in a file this PR
   already touches, with the fixture already present. Adds that assertion plus a negative
   control on a well-formed pair.

4. "A mismatched pair cannot be produced by the UI at all" WAS FALSE, and the event name
   inherited the false premise. `buzzPriceMetadataSchema.buzzAmount` is independent, with
   a sibling `bonusDescription`; the form submits `selectedPrice.buzzAmount ?? unitAmount
   * 10`. A bonus-Buzz Price (charge 1000, credit 11000) trips the guard from an ORDINARY
   package click. Checked live: all five buzz Prices carry empty metadata, so this is
   latent, not active. Renamed `-tamper` -> `-mismatch` so configuring the next bonus
   package does not attach the word "tamper", and an innocent buyer's userId, to a warning.

5. THE GUARD FAILED CORRECT CODE, TWICE. Single-sourcing the inverse derivation to
   `* BUZZ_PER_USD_CENT` turned it red with a message saying the form was open-coded —
   the opposite of what the developer had just done. And `stripComments` stripped only
   whole-line comments while claiming to strip all, so a trailing comment could fail the
   guard or satisfy a required count. Both fixed and pinned by mutants that must PASS.

6. An un-ceiled `minBuzzAmount / 10` at the min-amount placeholder (pre-dating this PR,
   2026-04-08) sat inside the file the guard's own test name claims to cover: for a
   fractional minimum the placeholder advertised one price while the seeded field held a
   higher one. Now uses the helper, which is what made the zero-division assertion in (1)
   possible.

Mutation battery, green control, each mutant killed by this guard's OWN named assertion:
DIE — revert call site to `/ 10`; the `/ BUZZ_PER_USD_CENT` spelling; DELETE a call site;
server ratio `/ 10` -> `/ 20`; `ceil` -> `round`; the parenthesised-division mutant;
the wire-value rewrite; deleting the mismatch log; a new importer that open-codes.
PASS — the `* BUZZ_PER_USD_CENT` consolidation; a trailing comment.

The DELETE-a-call-site mutant SURVIVED the first round-3 attempt, because this commit
initially relaxed the count to `>= 2` and three sites minus one still satisfies it. Caught
by this round's own battery and pinned back to an exact count.

Verified at the merged tree: 5 files / 39 tests green, no zero-test non-runs, drift
ratchet green, prettier clean.

* fix(buzz): delete the call-site guard — .int() already makes the revert loud

Round 4 defeated `no-open-coded-buzz-cents` FIVE independent ways, and its docblock
claimed a strength it did not have — the same defect round 2 had already flagged, made
again one round later. Rather than escalate a sixth time, this runs the change through
the five-step algorithm, and step 1 answers it.

QUESTION THE REQUIREMENT. The guard has no maker: it was suggested by the round-1 audit
and written by me. A prior agent session is not a requester. Recurrence of the incident
it prevents is ZERO — the live defect (a fractional amount reaching Stripe) fired once in
seven days, while the revert it guards against has never happened. Its standing cost is
measured, not speculative: three audit rounds, a four-file doc tax whose counts must be
recomputed on the merged tree, a merge conflict that did not previously exist, plus two
defects live in the tree today — a comment-stripper that deletes real code at
`BuzzPurchaseImproved.tsx:401` (a `//` inside a template literal), and a Tailwind
exclusion that is backwards from what its own comment claims, so `rounded
border-white/10` reds the gate while `border-white/10 rounded` passes.

And the fact that settles it: `.int()` on `paymentIntentCreationSchema.unitAmount` IS the
guard. Reverting the client-side ceil no longer corrupts anything silently — it is
rejected at our own trust boundary and Pay Now fails outright, which is loud and
immediate. A standing source-text tax to protect a loud failure is a net loss.

DELETE. The guard and its registration go; `test:lint-rules` returns to 47 files and the
guard count to 42. The ~10% normally added back is near zero here, because everything
worth keeping already lived elsewhere: `buzz-charge.test.ts` covers the ceil
behaviourally, and the mismatch-log test plus its negative control live in
`stripe.getPaymentIntent.buzz-currency.test.ts` — both retained, both mutation-proven
(logging unconditionally kills the control; dropping a payload field kills the positive).

SIMPLIFY. Two comment defects fixed, both of which an operator would act on:
  - `stripe.service.ts` asserted the tampered pair is "unreachable through the UI" eleven
    lines above the block explaining a bonus-Buzz Price reaches it from an ordinary
    package click. The file contradicted itself, and the surviving sentence was the one
    implying a `buzz-purchase-amount-mismatch` event means tampering.
  - `payment-intent-unit-amount.schema.test.ts` said the `createBuzzSession` route "is NOT
    bounded anywhere" and described what to do "until #4955 lands". #4955 merged, so that
    route no longer exists; the docblock now records why it was deleted rather than bounded.

AUTOMATE LAST — explicitly NOT done. The fix for over-guarding is never another guard, so
no token-aware scanner and no meta-check.

NOT fixed, deliberately, and filed rather than folded in: `coinbase.service.ts:24` and
`:68` open-code an un-ceiled `buzzAmount / 10` on a live server path. Pre-existing, wider
than this PR, and the hazard the deleted guard was structurally unable to see. Closing
condition: a PR routing both sites through `buzzAmountToUnitAmount`, verified by the
helper's own tests.

Verified at the merged tree (post-#4955 main): 4 files / 30 tests green, no zero-test
non-runs, drift ratchet green on the deregistration.

* fix(buzz): bound the minor unit on every provider, not just Stripe

Round 6 refuted round 5's reasoning by measurement, and it was wrong in the direction
that matters. Round 5 deleted a structural guard on the grounds that `.int()` on
`paymentIntentCreationSchema.unitAmount` already made an open-coded cents division a loud
failure. `.int()` covered ONE of FOUR provider routes.

The purchase form hands the same derived `unitAmount` to `BuzzCoinbaseButton`. With the
client-side ceil reverted:
  - `coinbase.schema.ts` accepted `1000.4` (it was a bare `z.number()`);
  - `coinbase.service.ts`'s tamper check did NOT fire, because `unitAmount` and `buzzAmount`
    come from the SAME division, so a fractional pair is perfectly self-consistent —
    measured, it fires 0/12 on free-typed amounts where `.int()` rejects 12/12;
  - the fraction left our boundary as `local_price.amount = "10.004"`, a sub-cent USD price.

Three files in this tree already said so — `BuzzPurchaseImproved.tsx` ("the only derivation
that reaches every provider, Stripe's `.int()` covering Stripe alone"), `stripe.schema.ts`
("this covers the STRIPE route only … a deferral, not a clean bill of health") and
`buzz-charge.ts`. The round-5 rationale contradicted all three.

So rather than restore the source-text guard, this makes the claim TRUE:

  - `.int()` on `coinbase.schema.ts`, `emerchantpay.schema.ts` and `paddle.schema.ts`, so a
    fraction is refused at our own trust boundary on every route. Three payload lines, no
    scanner, no doc tax — which is why this passes the question-the-requirement test that
    the deleted guard failed.
  - `provider-unit-amount-int.schema.test.ts` pins all four, each with a positive control so
    a rejection cannot pass for the wrong reason. That control earned its place immediately:
    paddle's schema requires `recaptchaToken`, so the first draft's "rejects a fraction" case
    was passing because the schema refused everything.
  - Negative control watched: removing Coinbase's `.int()` reds the `coinbase` case
    specifically; restored byte-identical.

Existing bounds are preserved and asserted, so the integer rule did not swap one constraint
for another: paddle still rejects out-of-range, emerchantpay still rejects non-positive.

Also fixed, both introduced or left by earlier rounds of this PR:
  - `buzz-charge.test.ts` pointed at `no-open-coded-buzz-cents.test.ts`, which round 5
    deleted — a dangling reference this repo has no doc-rot gate to catch. It now says what
    actually catches a drifting server ratio (`stripe.getPaymentIntent.buzz-currency.test.ts`,
    and only incidentally, via its `buzzAmount = UNIT_AMOUNT * 10` fixture) rather than naming
    a file that does not exist.
  - `stripe.getPaymentIntent.buzz-currency.test.ts` still asserted the mismatched pair is
    "unreachable through the purchase form". That is the same false claim removed from
    `stripe.service.ts` last round, and it is the one that leads an operator to read a
    `buzz-purchase-amount-mismatch` event as tampering.
  - `buzz-charge.ts` now records why the schemas — not this helper, and not a provider's own
    tamper check — are the defence, so the next person does not re-derive the refuted version.

NOT restored: the deleted call-site guard. With every route bounded, the revert it existed to
catch is now loud everywhere rather than on Stripe alone, which is what round 5 claimed and
did not have.

Verified at the merged tree: 6 files / 58 tests green, no zero-test non-runs, drift ratchet
green.

* fix(buzz): retract the four claims round 7 falsified and left standing

Round 8 (delta audit of f534813f2c..9b153c91e9) returned five 🟡, none in the
payload: every one is a sentence an earlier round of this PR wrote, which a
later round of this same PR made false. Comment-only — mechanically confirmed,
no non-comment line changed.

F1 `stripe.schema.ts` — a four-row table this PR added, asserting the other
three providers carry no `.int()`, "(all verified at this commit)", ending "a
deferral, not a clean bill of health". Round 7 closed that deferral, so three
of the four rows and all four line numbers were wrong. The block recorded an
open gap that no longer exists, so it is DELETED rather than re-pinned; what
replaces it is the one fact that survives — the `.int()` is now shared, the
`.min`/`.max` are not — and a note not to re-add line numbers, since nothing in
this repo checks a cross-file reference and these rotted inside one PR.

F2 `buzz-charge.ts` — "refused at our own trust boundary on every route" is
false. `coinbase.createCodeOrder` accepts a `buzzAmount` and no `unitAmount`,
then divides by 10 inside `coinbase.service.ts`, downstream of every schema
bound. Verified: `createCodeOrderSchema.safeParse({type:'Buzz',
buzzAmount:10004})` succeeds and 10004/10 = 1000.4. Named as NOT covered rather
than quietly narrowed.

F3 `coinbase.schema.ts` + `buzz-charge.ts` — "the check fires 0/12 while
`.int()` rejects 12/12" named no population and is not reproducible: against
the only 12-element set in the tree it is 0/12 and 9/12, and against
"non-multiples of ten" both halves are true by construction. The comparative is
DELETED, not reversed. What replaces it is the structural claim, which is what
was actually established: the tamper check compares two values from the same
division, so every non-multiple of ten yields a self-consistent pair.

F4 `coinbase.schema.ts` — "same rule as the Stripe route" read as parity.
Coinbase got one of Stripe's three bounds; it still accepts a negative or 1e15.
Scoped to wholeness explicitly and the residual named as pre-existing.

F5 `BuzzPurchaseImproved.tsx` — "Stripe's `.int()` covering Stripe alone",
false since round 7 and quoted by round 7's own commit message as evidence.

F6 `stripe.getPaymentIntent.buzz-currency.test.ts` — the bonus-Buzz
reachability claim dropped the "latent rather than active" qualifier that
`stripe.service.ts` carries for the identical claim. Restored, so the two files
state the same strength.

Retraction swept tree-wide, not edited at the reported sites: `0/12`, `12/12`,
`covering Stripe alone`, `NO bound at all`, `clean bill of health`,
`no-open-coded` all return zero on the payment surface, against a positive
control (`buzzAmountToUnitAmount`) watched to hit. The control moved 4 files to
3 — reconciled: the fourth was the deleted `stripe.schema.ts` table.

Verified at this tree: prettier clean on all five files with the instrument
validated first (5 operands; a planted mis-format made it go red; restored
byte-identical by sha256). ESLint 0 errors, 2 pre-existing warnings. Affected
suites 4 files / 25 tests green — the `Tests` line, not `Test Files`.

Not established: whether "all five live buzz Prices carry empty metadata" still
holds — that is a production-database claim and is not checkable from here. It
is carried forward from round 7 unchanged, now in both files rather than one.

* fix(buzz): restore the true row round 9's deletion took with the false ones

Round 9 (delta audit of 9b153c91e9..819791aa15) returned two 🟡, both in the
retraction I wrote, neither with a live failure path. Comment-only again.

F2, and it is the one that mattered: the four-row provider table I deleted was
wrong in three rows and RIGHT in the fourth. `paddle.schema.ts`'s
`buzzPurchaseMetadataSchema.unitAmount` really is `z.coerce.number().positive()`
— no `.int()`, no `.max()` — and deleting the table left that recorded NOWHERE
in the tree. Worse, my replacement was written at FILE granularity ("paddle.ts
now carries the same .int()") when paddle holds two schemas with a `unitAmount`
and only the route-input one is bounded. `stripe.schema.ts` has the identical
shape in `paymentIntentMetadataSchema`, which was never in the table either.

Measured on the live schemas: `metadata.unitAmount = 1000.4` parses, parses
nested through `transactionCreateSchema` on the real route, coerces from the
string "1000.4", and accepts 1e15. Inert TODAY only because the services
rebuild metadata server-side (`paddle.service.ts` via
`getBuzzTransactionMetadata`) instead of forwarding the client's — a one-line
refactor away from reaching the provider. That is exactly the read the deleted
row would have blocked, so both comments are now written per SCHEMA and the
unbounded nested pair is named explicitly.

F1: "nothing in this repo checks a cross-file reference" was false. This repo
DOES pin doc-vs-tree claims — `no-lint-rules-script-drift.test.ts` pins literal
sentences and asserts referenced paths exist, `no-stale-moderator-route-probe`
pins route paths. What has no gate is specifically a `file:LINE` reference
inside a source comment. Narrowed to the claim that is true, and the precedent
named so the next author can find it rather than concluding none exists.

G1, taken now rather than leaving it for a round that should not happen: the
structural claim's universal quantifier is false above 2^53 — 2^55+2 ends in 8
and still divides to an integer. Bounded to "the double arithmetic represents
exactly", with the counterexample and the reason it does not move the
conclusion (up there NEITHER defence fires).

🔴 THIS IS THE LAST ROUND, AND IT STOPS ON THE ATTRIBUTION GATE, NOT ON A CLEAN
RESULT. Round 9 changed 70 payload lines and ZERO behavioural ones; this round
does the same. Two consecutive rounds whose fixes changed no executable line
means the ladder is auditing its own prose rather than the PR — the payload has
been unchanged since round 7 and was found correct there under seven mutants
with four proven-non-vacuous positive controls. F1 and F2 are the fifth and
sixth successive wrong-SCOPE rationale on this one family of comments; a tenth
round auditing a tenth rewording costs more than the sentences are worth. The
auditor independently recommended applying these and stopping.

Verified at this tree: comment-only (mechanically); prettier clean on 3 files,
3 operands; affected suites 3 files / 16 tests green, read off the `Tests` line.

Not established, unchanged and flagged rather than closed: "all five live buzz
Prices carry empty metadata, checked 2026-09-19" is a production-DB claim and
is not checkable from this host. Both files state it at the same strength.
2026-09-20 02:02:40 -05:00
Zachary Lowden a042ff3121 refactor(stripe): delete the dead createBuzzSession path instead of hardening it (#4955)
`createBuzzSession` had a missing integer bound — it hands
`unit_amount: customAmount * 100` to Stripe — so the obvious move was to add
`.int()` beside it. It is deleted instead, because nothing calls it and it is
an authenticated Stripe-calling surface.

Enumeration, complete over tracked files at origin/main rather than sampled:

  git grep createBuzzSession origin/main     -> 12 hits, all inside the chain itself
  git grep createCheckoutSession origin/main ->  3 hits: the definition, the object
                                                 it is returned on, and one docs line
                                                 about the unrelated
                                                 membershipGift.createCheckoutSession
  git grep useQueryBuzzPackages origin/main  ->  7 hits; the two consumers destructure
                                                 { completeStripeBuzzPurchaseMutation }
                                                 and { packages, isLoading, processing }

So the chain trpc.stripe.createBuzzSession -> createBuzzSessionHandler ->
createBuzzSession was reachable only over the network. It was annotated DEAD
CODE in two places already (the service comment, and the hook wrapper's own
"DEAD CODE: no callers"), and Buzz purchases have used the PaymentIntent flow
for some time.

Removed: the service function, the controller handler, the tRPC procedure, the
input schema and its inferred type, the client mutation and its
`createCheckoutSession` wrapper, the two now-unused type imports, AND the
service's six-line DEAD CODE comment block. That last one is not cosmetic: an
earlier revision of this change deleted the function and left the comment, which
then sat directly above `export const upsertSubscription` — the function the
`customer.subscription.*` webhook calls to sync `customerSubscription` — where
it reads as "DEAD CODE: no live callers" describing a live webhook path. The
toolchain checks nothing about comments, so only reading the file back catches
it. Verified at this commit: `createBuzzSession` has 0 occurrences under src/,
`upsertSubscription` carries no preceding comment, and the hook wrapper's own
DEAD CODE comment went with the function it described.

`getBuzzPackages` is untouched — it is live, and both components read `packages`
from it.

The limits of the evidence, stated rather than implied: the procedure carried
`.meta({ requiredScope: TokenScope.Full })`, so an API-token holder could in
principle have been calling it directly, which no grep over this repo can see.
The telemetry check below covers 14 days and the org's own traffic only. The one
check that would close it is Stripe-side — sessions created by this function
would appear in the Stripe dashboard — and it was not run.

Verification at this commit (worktree off origin/main 8aa9e5cef2, submodule and
prisma client present, so the base is genuinely green):

  pnpm typecheck                        -> 0 type errors in 72s
  pnpm test:unit:run src/server/schema src/server/routers src/server/controllers
                     src/components/Buzz
                                        -> Test Files 144 passed (144)
                                           Tests 2503 passed (2503)
  eslint on the changed files           -> 0 errors (14 warnings, all pre-existing;
                                           each named identifier appears exactly once
                                           at origin/main too, i.e. import-only before
                                           this change)
  prettier --list-different             -> only stripe.controller.ts, which is equally
                                           different at origin/main (pre-existing drift,
                                           deliberately not reformatted here)
2026-09-19 21:58:54 -05:00
Zachary Lowden f213521be0 fix(blocks): close six fail-open spelled guards in the block-token guard tests (#4984)
* fix(blocks): close six fail-open spelled guards in the block-token guard tests

Six weaknesses in the App Blocks bridge-token guard and its REST sibling, each
fail-open, each measured GREEN under its own evasion before the repair and RED
after (clawgate #589). Guard/scaffolding layer only — no runtime file is touched.

The class is not theoretical: the same shape recently let a live REST route pass
24/24 with no token verification, no revocation check and no approved-status gate,
because the assertion whose stated purpose was that regression read the raw file
and a commented-out wrapper satisfied it.

The six, with the walk that was open and what closes it:

1. PROC_RE pinned the procedure BUILDER's spelling, so `evasiveProc: t.procedure`
   taking a blockToken and guarding nothing was outside the derived population.
   Closed by a derived cross-check on a different surface: every tRPC terminator
   in the router must land in exactly one PROC_RE-named chunk, counted from the
   parse so a computed `['mutation'](` is counted too.
2. GUARD_CALL_RE ran on RAW chunk text, so commenting out a proc's guard call and
   decoding the token instead still read as reaching the guard. `chunks` now
   carries a normalised slice and every reachability decision reads it.
3. `scan(read(GUARD)).direct` counted matching LINES of raw text, so one prose
   sentence writing `verifyBlockToken(blockToken)` let the REAL call be deleted
   while the count stayed at 1. It now counts CALLS on normalised code.
4. The REST opt-out population could not see `onApprovalLookupFailure` arriving by
   object spread, so tip.ts — the irreversible Buzz transfer — opted out of failing
   closed invisibly. Closed by pinning the options object's SHAPE.
5. RESERVED_WORDS was unpinned while its neighbour MODULE_EXEMPTIONS was pinned,
   so the anti-suppression pin was evadable through the adjacent set. Closed by a
   subset test against a language-level word list.
6. `status === 'approved'` and friends were satisfiable by a STRING literal,
   because the filter stripped comment lines and nothing else. Closed by
   normalising literals behind a sentinel no literal body can forge.

The normaliser is built on the TypeScript parser rather than a hand-rolled lexer,
because the lexer was measured wrong on this corpus in three ways that are all
fail-open: a nested template inverts which regions are code (21 live instances
under src/pages/api), a regex literal desyncs it (`/^https?:\/\//` appears twice
in blocks.router.ts), and `${}` interpolation is real code it swallowed.

Every normaliser entry point has a positive control: an identity `return source;`
on any of the six fails at least one assertion. Full mutation matrix in the PR body.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(blocks): correct the nested-template count to a figure I measured

The docstrings said "21 nested templates under src/pages/api", a number taken from
a review report rather than measured here. Re-derived from the TypeScript parse
(a template literal lexically inside another): 35 across 14 files, 2026-09-19.

The claim the number supports is unchanged and if anything stronger — the shape is
ordinary, not exotic — but a figure standing in a committed docstring has to be one
this change actually took.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(blocks): state the lexer-vs-parser measurement at the scope it was taken

The docstrings attributed "disagreed on roughly half" to the 345 files these two
suites read. The sweep that produced it covered 1,724 files under src/server and
src/pages, and found 858 disagreements — a wider population than the sentence
named, so the rate did not belong to the set it was attached to.

Restated with the real denominator and an explicit note that it is the wider sweep,
so it reads as "endemic in the corpus" rather than as a rate for the scanned set.
No assertion changes; 65 tests still pass.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 20:25:09 -05:00
Zachary Lowden e601fa4f5c refactor(app-blocks): route the four per-call budget gates through one helper (#4983)
No behaviour change. The four submit gates each spelled the same
`cost > claims.buzzBudget` comparison inline; they now call one exported
helper, `blockPerCallBudget(claims, { pricesAuthorFee })`, which returns
`claims.buzzBudget` on both classifications. Every gate compares the same
number it compared before.

The gates are not interchangeable even though they spell the same thing:
two add the per-generation author fee into the value they compare, and two
do not — and on the pass-through path the value that clears the gate is the
value reserved and the value the terminal settle bills. Any future change to
the ceiling is correct for at most one of those populations, so it has to be
made somewhere that knows which gate is asking. `pricesAuthorFee` is how each
gate declares which it is; it is classification only and no branch reads it.

Adds `no-direct-block-budget-claim-read`, a structural guard: every
occurrence of `claims.buzzBudget` in production source must match one of four
enumerated allowed forms (the presence pre-check, the single mint-site write,
the helper's own return, the two read-surface projections) or it fails,
naming file, line and text. It scans every production .ts/.tsx under src/,
blanks comments before matching, and finds call sites by balanced-paren
extraction, so a new spelling, a gate in another module, or a prettier
line-wrap cannot hide one.

This replaces an earlier version of this branch that granted author-fee
headroom at token mint time. That is withdrawn: the raised ceiling was not
consumed by a fee on the two fee-free gates, where it would have been
reserved and billed above the ceiling the app's manifest declared, and it was
equally unconsumed on a fee-pricing gate whenever the fee prices to zero.
2026-09-19 18:36:23 -05:00
Zachary Lowden 43d48b42c4 fix(blocks): narrow the dev-token exemption so a suspended app stops driving the bridge for 4h (#4980)
* fix(blocks): the dev-token exemption no longer skips the approved check for every dev token

The App Blocks approved-status predicate short-circuited on `claims.dev === true`,
so a moderator suspension left already-minted dev tokens driving both halves of the
runtime — the 15 tRPC bridge procedures and every `withBlockScope` REST route — for
the remaining life of the token. Dev tokens live 14400s against a 900s default, so
that window was 16x every other token's, on the class with the widest scopes.

The `dev` claim is stamped unconditionally by `signDevScopedPageToken`, which six
mint paths reach. Three of them must run a non-approved app; three must not. Keying
on the bare boolean exempted all six, and the docblock justified it by listing belts
the guard re-checked none of — ownership, an active dev tunnel, the author flags,
the approved-scope clamp.

The predicate now separates them:

  - a signed `reviewRunForReal` claim short-circuits ahead of the read, which is the
    moderator review sandbox and the one population that must run a non-approved app
    without owning it;
  - no backing row means nothing to be approved, covering the synthetic-id mints;
  - a REAL row that is not approved is exempt only when the subject IS the app's
    owner AND that owner has an ACTIVE dev tunnel for the slug — the two preconditions
    the owner-dev-tunnel mint enforces, re-derived rather than assumed;
  - anything else with a real, non-approved row is refused, which is the `dev:live`
    token whose mint required approval and that has simply outlived it.

Ownership comes from one extra selected column on the lookup this path already
performs. The tunnel check is reached only on the dev + real-row + not-approved
path, so no approved app and no non-dev token pays for it.

Revocation is untouched — it was never exempted — and the 4h lifetime is unchanged.

Refs clawgate #571.

* fix(blocks): resolve the dev-token owner in the branch, not as a second query on every request

Findings from this repo's five-lane review, applied. Three of them were wrong
in ways a green suite could not show.

PERF — the owner was read as `app: { select: { userId: true } }` on the row
lookup, and two comments asserted that was "a join on the FK, not a second
query". It is not: the schema enables only `previewFeatures = ["metrics"]`, so
with no `relationJoins` Prisma resolves a nested relation with another round
trip, and because `appId` is a REQUIRED relation that query cannot be skipped on
an empty FK set. It would have fired for every token whose row exists — every
bridge call including the timer-driven `pollWorkflow`, and every block-JWT REST
request — to read a column only the dev branch consults. `claims.appId` IS the
`OauthClient.id`, so the lookup moves into the branch as the same primary-key
read, issued only when needed. The row read returns to `select: { status: true }`
and a test now pins that literal.

TESTS — moving the `dev` check behind the row read silently made the existing
`dev=%p is NOT exempt` table VACUOUS. Its fixture carried no owner, so the
mutant `!claims.dev` fell through to the ownership guard and was refused there:
the table stayed green while the guard it claims to pin never executed. The
repair is in the fixture — clear every other reason to refuse, so the verdict is
attributable to the `dev` comparison alone. Six mutants now each die to their own
test, including this one.

NARROWING — `reviewRunForReal` was exempting without requiring `dev`. Every mint
stamps both, but `sign` accepts the field independently, and a bypass keyed on one
signed boolean without narrowing it is the defect this change exists to fix; alone
it would have been WIDER than the blanket exemption it replaced.

POSTURE — `getActiveDevTunnel` attaches its `.catch()` to the result of
`sysRedis.get(...)`, so a synchronous client throw escapes it, as can the dynamic
import. Unwrapped that becomes a 503 blamed on the replica read. Now wrapped, so
the fail-closed posture is written rather than inherited.

REUSE — `subjectForUserId` already existed in `block-revocation.service` under a
docblock claiming to be "THE ONE PLACE this format is written on the WRITE side".
It was not — the mint open-coded the same template — and this change would have
been a third copy, each pinned by its own literal so no test could see them
diverge. Moved to a zero-import leaf both now use, re-exported so no importer
changes.

Plus the written half: the population table the code referred to now exists as a
table, the `ai:write:budgeted` containment claim is scoped to the population it
is actually true of, and the two sibling resolvers each carry a cross-reference
to the other two.

Refs clawgate #571.

* fix(blocks): log the dev-tunnel re-check failure instead of folding it into the refusal count

Delta round on the previous commit's fixes. The wrapper added there was right
about the verdict and wrong about observability.

A throw out of the dev-tunnel re-check used to reach `resolveRestApprovalVerdict`,
get logged through the throttled limiter with its error message, and answer
`lookup_failed`. Wrapping it turned that into a bare `catch` returning
`not_approved` — correct as a verdict, but it increments the SAME series this
whole change ships to be watched on. A sysRedis fault would have pushed every
population-E owner into `not_approved` fleet-wide with nothing logged anywhere,
and the predicate's own docblock reads that series as "the 4h window closing" —
so the incident would have read as the narrowing working. The one leg the change
added was the one leg it made unobservable.

It now logs, with its own message and its own throttle window. Deliberately a log
rather than a new verdict: a `tunnel_lookup_failed` would have to be mapped by both
callers, and the REST mapping for an unrecognised verdict is 503 — the exact
misattribution (a cache fault blamed on the replica read) the wrapper exists to
avoid. Separate windows because two failure modes sharing one would suppress each
other, and the one you did not see would be the one you needed.

The throttle logic is now written once and closed over per caller, rather than
open-coded twice.

Also from the same round:
- `parseSubjectUserId` and five self-scope gates still spelled 'anon' as a literal
  while the new leaf claimed one spelling for the format. They use ANON_SUBJECT now,
  so the claim is true rather than nearly true.
- `publisher-ban-revocation` took `subjectForUserId` through
  `block-revocation.service`'s re-export — a module wholesale-mocked in a dozen
  suites with a factory exporting only `BlockRevocation`, which is the shape the
  leaf was extracted to avoid. It imports the leaf directly.

Refs clawgate #571.

* docs(blocks): correct four claims the last round's fix made false

Round 3 of the audit ladder. No behaviour change — every finding was a comment
that the code contradicts, which is the class this file keeps producing because
its docblocks carry the reasoning rather than just describing it.

1. The new tunnel-logger docblock said a dedicated verdict was avoided because
   "the REST mapping for an unknown verdict is 503". It is not. The chain in
   `withBlockScope` is not_approved -> 403, lookup_failed -> 503, and then an
   `else` that asserts `satisfies 'not_found'`, logs "SERVING (observe-only)" and
   falls through to the handler. REST's runtime default for an unrecognised
   verdict is to SERVE, and it would log the request as a missing row it is not.
   The bridge is the opposite: `satisfies never` then an unconditional FORBIDDEN.
   So the paragraph told the next author REST fails closed on the exact branch
   where it fails open. The real reasons — the two-caller mapping burden, forced
   by the compile error at that `satisfies`, and REST's serve-by-default — are
   now what it says. It also inverted the trade: a dedicated verdict would be
   BETTER attribution than the log, since it would carry its own reason= label
   instead of sharing not_approved. Recorded as such, so "add the verdict" stays
   available as the deliberate change rather than looking already-rejected.

2. `LOOKUP_FAILURE_LOG_WINDOW_MS`'s docblock now sits above both loggers while
   asserting "THE COUNT IS NOT THE ALERTING SIGNAL — the unthrottled
   reason=lookup_failed series is". True of the replica-read logger, false of the
   tunnel one, whose failures resolve to not_approved and have no dedicated label:
   there the throttled log IS the only signal, so a suppressed line is lost
   information rather than redundant prose. Same window, opposite relationship to
   the metrics.

3. The predicate's "IT DOES NOT CATCH" heading is a blanket claim the previous
   commit falsified 130 lines below it, and it names as an anti-pattern exactly
   what the tunnel leg now does. Scoped to the ROW reads, with the reason the
   exception is right there and wrong for them: an unreachable replica means "we
   cannot establish whether this app may run", which is a different question per
   caller; an unreachable tunnel cache means "no live tunnel", which is the same
   fail-closed answer everywhere.

4. The bridge's "a read that THROWS propagates as the tRPC internal error" is no
   longer true of the tunnel read — that caller now gets FORBIDDEN plus a warn
   this path never emitted. Scoped, and flagged as the behaviour change it is.

Also: the new log test relied on being the first tunnel failure in the process
for its toHaveBeenCalledTimes(1). It resets the window instead, so it no longer
breaks based on where it sits in the file.

Refs clawgate #571.

* fix(blocks): give the dev-tunnel failure its own verdict — the log it had cannot be read here

Round 4 of the ladder, and it overturns round 2's fix rather than refining it.

Round 2 found the dev-tunnel re-check swallowing a throw into `not_approved`
with no signal, and answered it with a throttled `console.warn`. That answer is
inert on this deployment: `app-block-runtime.metrics.ts` states twice, and
designs around, the fact that application-container logs are NOT collected here
— "the `console.error` shape used elsewhere in the repo would be invisible to a
later investigator". So the fix swapped a silent swallow for an unreadable one,
and the paragraph arguing the log was the signal separating a cache incident
from the stale-token population was wrong about its own environment.

The leg now returns `tunnel_lookup_failed`. It refuses identically to
`not_approved` on both callers — same status, same message, deliberately, since
a bearer learning that the dev-tunnel cache is down would be an infrastructure
oracle — but it carries its own `reason=` label, so an operator can tell a
sysRedis fault from the population this change exists to create. That matters
because `not_approved` is the series the whole narrowing is watched on: folded in,
an incident reads as the fix working.

NOT reused `lookup_failed`, which was the tidier-looking option: that verdict
means the REPLICA read failed, answers 503, and is SERVED on the five routes
declaring `onApprovalLookupFailure: 'serve'`. Both would be wrong — a cache fault
blamed on the database, and a non-approved app served on some routes.

Four edit sites, all compiler-forced: the verdict union, both caller mappings,
and the metric's reason union. The cardinality guard went 3 -> 4 series; its
budget is a deliberate literal, so the new label is argued in place rather than
waved through, and the test now drives the new reason instead of only declaring
it.

Three prose corrections from the same round, each a claim the code denies:
- "the window is shared because the rate argument is identical" — it is not. The
  replica logger's case is fleet-wide simultaneity; the tunnel logger is reachable
  on one owner's one app. By this repo's own reasoning that shape needs no throttle
  at all. Kept, with the real reason.
- "a dedicated verdict would carry its own label" was true only for REST: the
  bridge records no verdict metric at all, and the label union is a third edit
  site rather than derived.
- "`lookup_failed` -> 503" is route-dependent, the same shape of flat claim round 3
  existed to remove, one verdict over.

Refs clawgate #571.

* docs(blocks): teach the verdict docs about the fourth reason, and stop claiming the bridge is covered

Round 5. No behaviour change. Adding `tunnel_lookup_failed` last round left six
places describing a three-verdict world, including the two an operator actually
reads, and repeated this change's own recurring mistake: writing a justification
one surface wider than the fix reaches.

THE ONE THAT MATTERS. The claim that a suppressed log now "loses prose and not
information" is true on REST only. `recordBlockRestApprovalVerdict` has a single
production call site, in `withBlockScope`; `assertAppBlockApproved` resolves the
same verdict and records nothing. So a tunnel-cache fault reached through the
BRIDGE emits no counter at all, and its only trace is the throttled warn — on a
deployment that does not collect container logs. That gap predates this work and
is equally true of `not_approved` (the bridge has never recorded a verdict), so
closing it is a bridge-metrics change rather than a guard one and is not taken
here. What is taken is saying so, in all three places that would otherwise let a
reader infer from the REST series that the bridge is covered — including the
explicit warning that a zero on `reason="tunnel_lookup_failed"` does not mean the
leg is healthy, given the bridge is the higher-rate surface (`pollWorkflow` is
timer-driven).

The operator-facing docs now know the label exists. The metric's `help` string
and its reader table enumerated three reasons and read as exhaustive, which is
the one place a description of `reason` reaches whoever is looking at the series
in Grafana — the whole point of the label was that someone can tell a sysRedis
fault from the stale-token population, and it was undocumented. Likewise the
verdict table in `block-scope.middleware`, which is the gate's own explanation of
the branch chain the last commit edited fourteen lines below it. That table now
carries the row that does NOT bend: `onApprovalLookupFailure` is scoped to
`lookup_failed` alone, so a route wanting lookup-failure tolerance does not get
it here — serving a known non-approved app because a cache was down is not
tolerating an unknown.

Count corrections: 3 -> 4 across the metric docblocks, the middleware table, the
emitter's own docblock and the metrics-test prose.

Two more claims the code denied:
- `resolveRestApprovalVerdict`'s docblock is the canonical mapping (the middleware
  points at it rather than restating), and it still listed three verdicts AND
  repeated the flat "`lookup_failed` (503) refuse" that the previous commit had
  corrected 240 lines above. Fixed one copy, left the authoritative one.
- The `tunnelFailureLog` docblock still opened, present tense, with "converts a
  throw into `not_approved`" while its own third paragraph said otherwise.

And two of my own from last round, which is the pattern:
- The new metrics-test comment claimed it proved a production caller emits the
  reason. It cannot — the loop iterates the union, so a phantom nobody emits
  satisfies it identically. The real pin is in the approved-gate suite, against
  the real middleware; this one is the cardinality half and now says so.
- "That second shape does not need throttling at all" understated the tunnel
  logger's case: a sysRedis incident hits every pod at once too, so it is the same
  simultaneity over a smaller population, not a different shape.

Refs clawgate #571.

* docs(blocks): finish the fourth reason — the count said four where the list said three

Round 6. No behaviour change. Both findings are the previous commit's own half-done
edits, which is the pattern this ladder keeps producing.

The metrics test's reader block was bumped to "the four reasons" over an
enumeration that still listed three. Before that bump it was stale but coherent;
after it, a reader counting rows finds one reason undocumented and has to go
looking for which — and the missing row is the one carrying the new operational
fact, that `tunnel_lookup_failed` is 403 on every route because
`onApprovalLookupFailure` does not reach it. That block is the third reader table
in the same family as the metric `help` string and the middleware table; the other
two got the row and it did not.

Same edit, second instance: a test title went "the three reasons are SEPARATE
series" -> "the four", while its body still drove three. The fourth reason's
separateness was covered elsewhere, so nothing was unproven — but the title read
as the proof and was not, which is exactly the over-claim the same file corrects
two cases below. The case now drives what its title counts.

And the REST-only scoping from last round reached three sites but not the two
that most needed it:
- The `catch` comment inside the SHARED predicate said "the counter is the
  signal", flat. That function is the one both callers use and the place a reader
  is standing when they ask whether this is observable — and on the bridge there
  is no counter.
- The `tunnelFailureLog` docblock closed with "no longer load-bearing", which
  directly contradicted the paragraph ninety lines above it stating that a bridge
  tunnel failure has this line as its only trace. Both cannot be true of one
  logger. The asymmetry is now stated as the argument it is: for giving the bridge
  a verdict counter, not for trusting the log.

Refs clawgate #571.

* docs(blocks): the comment welded two slips into one moment, and pointed at the wrong case

Round 7, and both findings are in the five-line comment round 6 added.

It said the reason was "left out when the reason was added — the title said four
while the body drove three". Checked against the branch: at the commit that added
the reason the title still said THREE, and the case was internally consistent —
under-covering the new reason, but claiming nothing it did not prove. The title
was bumped to four in the following docs pass, and that is where it became a
coverage claim wider than the test. Two slips, one commit apart, welded onto a
moment neither of them happened at. The commit message for that pass had the
history right; the in-file comment was a degraded restatement of it.

And "two cases below" is off by one — the case that corrects the same shape is
`emits AT MOST 4 series`, three below. Named rather than counted now, so it
cannot drift again when a case is inserted.

Refs clawgate #571.
2026-09-19 17:24:30 -05:00
Zachary Lowden e81bcc973f fix(test-cache): normalise the leading slash POSIX fileURLToPath adds to a Windows file URL (#4981)
* fix(test-cache): normalise the leading slash POSIX fileURLToPath adds to a Windows file URL

`scripts/__tests__/test-cache-core.test.ts` has been red on every pull request
opened since 07:13Z today, failing one assertion of 32:

    × gives the same path for the same file in two worktrees, URL or path
    AssertionError: expected null to be 'src/a.ts'

Root cause, established by executing the function rather than reading it:
`fileURLToPath` is platform-dependent. Given `file:///C:/Dev/wt/two/src/a.ts`
it returns `C:\Dev\wt\two\src\a.ts` on Windows but `/C:/Dev/wt/two/src/a.ts`
on POSIX — with a leading slash. Neither drive-letter comparison in `toRel`
can see past that slash, so the path stops matching `root` and `isAbsolute()`
returns null instead of the relative path. The test asserts the Windows
result; CI runs Linux.

The fix normalises the leading slash away immediately after the conversion, so
the drive-letter forms below it read the same shape whichever platform
resolved the URL. It is one line in the shared prefix of every path reaching
this function, which is why the diff carries more comment than code.

Verified:

- red at the pre-fix commit, green at HEAD. With the fix reverted and the test
  file unchanged: 2 failed | 32 passed. With the fix: 34 passed.
- Six control cases executed before and after — both POSIX spellings, a POSIX
  path outside the root, an already-relative path, the Windows non-URL path,
  and a Windows path outside the root. All unchanged by the fix. Only the
  failing case moves.

Two tests added, and they are labelled from what they were MEASURED to do at
the pre-fix commit rather than from what they were written to do. The first
draft called both "invariant"; the run showed the second one fails at base, so
it is regression coverage and is now named that. The POSIX one does pass at
base and stays labelled an invariant guard, so nobody counts it as regression
coverage it does not provide.

Scope note: this is a fix for a defect on main, deliberately kept out of the
unrelated PR that surfaced it. #4971, which introduced the test, merged at
07:13:43Z with this same shard already failing on its own head commit.

One judgement worth flagging for review: a POSIX path whose first segment is
literally a single letter and a colon (`/C:/…`) would now be rewritten. That
spelling is pathological on POSIX and cannot be produced by `fileURLToPath`
from a non-Windows URL, but it is the one input whose handling this changes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test-cache): scope the normalisation to the file:// branch, and drop a subsumed test

Round 0 of the pre-merge audit found two things, both acted on here. Neither
is a correctness defect in the shipped fix — CI was fully green at 20282b3e77,
19 entries, 18 success, 1 skipped, shard 2 passing.

1. NARROWED (R1). The normalisation ran on every input, so it also rewrote a
   genuinely POSIX path whose first segment is a letter and a colon. Measured:
   toRel('/C:/notes/x.md', '/C:') returned 'notes/x.md' before and null after.
   That was the one behaviour change the PR body had to flag for review.

   Only a file:// id can carry the platform artefact, so only a file:// id
   needs the repair. Moving it inside that branch satisfies the requirement
   exactly and leaves every non-URL input byte-for-byte as it was. The flagged
   behaviour change is gone rather than documented.

   Measured across all three variants on 8 cases: base fails only the Windows
   file:// URL case; the unscoped draft fixes that but breaks the POSIX
   pseudo-drive case; the narrowed version is correct on all 8.

2. DELETED a test I added (the "regression" equality at :93-103). The audit
   ran a mutation table I had not. Against four mutants — normalisation
   deleted, inverted, prefix-compare broken, and "strip any leading slash" —
   that test killed only the first, which the pre-existing assertion at :71-72
   already kills, and it PASSED both the inverted and broken-prefix mutants.
   An equality with no anchor is satisfied when both sides return null, which
   is the property I had described as its strength. It is subsumed, and
   strictly weaker than the assertion that surfaced the bug.

   A comment now records why it was removed, so it is not re-added as an
   apparent improvement.

The invariant guard survives and is unchanged: it is the only thing in the
file that kills the "strip any leading slash" mutant, and every other toRel
assertion here is Windows-shaped while CI and every Linux/macOS dev run POSIX.
A new invariant guard pins the POSIX pseudo-drive case the narrowing protects.

Re-verified after the change, because an audit fix resets the verification
gate: 34 passed at HEAD; at the pre-fix commit 1 failed | 33 passed, the single
failure being the genuine regression guard at :71-72. That is a cleaner control
than the previous revision, where two failed because the subsumed test failed
alongside it.

Round 0 reports and does not move the ladder; its advisory verdict was safe to
merge, and the requirement survived questioning — the Windows fixture is this
repo's only Windows coverage for toRel, since no workflow runs vitest on a
Windows runner, so platform-gating or deleting it would zero that coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 14:22:18 -05:00
Zachary Lowden 981843387e Apps earnings copy no longer promises a payout cadence that does not run (#4979)
* fix(apps): earnings copy no longer promises a payout cadence that does not run

Three user-facing strings asserted an automated payout pipeline that is not
wired, on surfaces the app-author cohort reaches today.

`mintPayoutForOwner` is the only writer of `paidOutAt`/`payoutId` and has no
production caller — every non-test reference is a prose comment. The weekly
`bulk-payout-block-attributions` job IS registered and does run, but is an
explicit stub: it aggregates, logs, and writes nothing. So the cadence existed
only in the copy.

- src/pages/apps/revenue.tsx: "Payouts are batched weekly" -> confirmed
  earnings accrue, automated payouts are not yet enabled. The pointer to Apps
  for managing installations is unchanged.
- src/components/AppBlocks/RevenuePanel.tsx: the Confirmed (unpaid) tooltip no
  longer promises inclusion in a "next payout"; it states accrual.
- src/components/Apps/AppEarningsPanel.tsx: the docblock claimed the earnings
  proc "grants it to any accepted editor with a session". It does not —
  `getAppEarnings` is an `appDeveloperProcedure`, so it refuses any caller
  outside the app-author cohort, and such an editor never reaches the panel
  because the authoring-context proc carries the same middleware. The
  paragraph's rationale for the panel existing is kept; only the access claim
  is corrected.

Adds src/components/AppBlocks/__tests__/payout-copy-truthfulness.test.ts,
which pins the two user-facing strings WHOLE and normalised (a banned-word
grep is walkable by rewording) and ties them to two state guards: the payout
rail has no production caller, and the weekly job performs no writes. Wiring
the rail fails the state guard first, which is the signal to revisit the copy.

Red at origin/main, green at HEAD: the three copy/docblock assertions fail on
pre-change content while the state guards stay green, so each fails for its
own reason rather than behind an earlier throw.

Not changed, deliberately: the payout rail itself, the Earnings tab and
capability, the invite disclosure copy (it promises visibility, not money),
the "Paid out" / "Confirmed (unpaid)" card labels (real status buckets), and
the Pending "settles after the refund window" tooltip (settlement and payout
are different claims).

* fix(apps): address review findings on the earnings-copy guard

Four of the five pre-completion review lanes reported; findings resolved:

- Reuse: the guard hand-rolled a `stripComments` that already exists as a shared
  module (`test/strip-comments.ts`), and the local copy was the weaker one — it
  collapsed block comments to '' rather than ' ' (which can join tokens across a
  stripped comment) and did not strip STRING literals at all. Now imports the
  shared `stripComments`/`stripCommentsAndStrings`. The caller scan uses the
  strings-stripped variant, since a name inside a string literal is not a call.

- Correctness: the guard's own header claimed "three user-visible strings" when
  one of the three is a docblock, reachable by no cohort. Corrected — in a file
  whose thesis is that committed prose must be machine-checkable, that was
  exactly the kind of unchecked claim it exists to forbid.

- Correctness: the "do not soften the disclosure" rationale in AppEarningsPanel
  was built on the sentence this change retracts, so it no longer followed from
  anything. Re-anchored to the reason that does hold: the cohort gate is a
  runtime Flipt toggle, so copy softened to today's narrow gate becomes an
  under-disclosure the moment the flag widens, with no code change and no PR.

- Intent + Correctness: the caller scan walked `src/` only, making its headline
  ("no production caller") wider than what it measured. Now walks `src`,
  `packages`, `apps` and `scripts`. The residual gaps it still cannot see — a
  renaming import, a computed member access — are stated in the file rather than
  implied away.

- Reuse: the guard's population was two hand-named files, i.e. only the SHRINK
  half of a ledger. Nothing failed when a THIRD cadence sentence appeared on an
  unlisted surface, which is the condition that let one wrong claim become three.
  Adds a GROW half: a cadence-phrase scan over the App Blocks / Apps / apps-pages
  money surfaces, carrying both a positive control (it must catch the two
  sentences this PR removed) and a negative control (the legitimate "Paid out" /
  "Not paid out" bucket labels and the new accrual copy must not trip it).

Also drops the negative grep for the retracted docblock sentence. The rewrite now
QUOTES that sentence so the next reader knows what was wrong, so a "must not
contain" assertion would have forbidden the clearest way to document the
correction — and it was a spelled guard regardless. Replaced with a state tie:
the router declares `getAppEarnings` on `appDeveloperProcedure`, trpc.ts defines
that as `protectedProcedure.use(hasAppBlocksAuthor)` throwing FORBIDDEN, and the
docblock names both the procedure and the cohort. Widened the job write-scan to
cover delete/deleteMany and the raw-SQL escape hatches.

Red at origin/main, green at HEAD, re-established for the revised guard: the
three copy/docblock cases AND the new GROW case fail on pre-change content while
the sanity and two state guards stay green — so each fails for its own reason.
The GROW half going red at main is the direct evidence it would have caught this.

Perf lane: no findings — the added tree walk is the 97th in the unit suite and
about 1% of a cost already paid; sharing it is impossible under `pool: 'forks'`
with `isolate: true`.

* fix(apps): harden the earnings-copy guard against the test lane's findings

The test-review lane measured two ways this guard could pass while wrong.

F1 — POLARITY INVERSION (the serious one). `test/strip-comments` documents
itself as biased toward over-stripping because that "turns the guard RED, which
is the safe direction". That holds for its other callers, which assert a call IS
present. This guard asserts ABSENCE, so the same bias turns it GREEN: a real
call the stripper ate reads as "no caller". Measured, ~105 files across the
scanned roots have real code hidden from the stripper (a `/*` inside a `//`
comment, or a regex literal ending `\/`), including files under services/blocks
— the payout rail's own neighbourhood.

The caller scan is now an exact per-file OCCURRENCE ledger over RAW text: three
files, with counts. It fails when the set grows, when it shrinks, or when any
count moves. That also closes the shapes a call-shaped regex missed for free —
renaming import, bare callback reference, `.call`/`.apply`, computed access —
because the bare identifier is what is counted and the import is the tripwire.
Stripping is now used only for "this code is NOT here" checks on files already
pinned by name, where over-stripping cannot manufacture a pass on its own.

F2 — the cadence-phrase GROW scan is REMOVED, not tuned. Measured: ten realistic
re-promises evaded it ("Payouts run weekly", "disbursed every Monday", "You get
paid every week"), and six TRUE statements tripped it, including "Payouts are
processed manually until the automated rail lands" and anything using "will be
paid" — so it forbade the accrual-truthful phrasing this PR institutionalises.
It also read raw text, so a comment quoting the retracted sentence as
documentation would have failed the build: an unlandable guard, which is worse
than none because it gets deleted rather than obeyed. English cadence is not a
regex problem.

Replaced with a STRUCTURAL ledger of the files that render settlement buckets.
That population is stable (this PR's scope deliberately keeps those labels), so
a third money surface fails and its author has to decide consciously whether it
needs the accrual disclosure the other two carry.

Also from the lane:
- F3: the docblock case's name was wider than its body — two bare identifier
  probes would pass a docblock that re-asserted the retracted claim. Now pins
  the correction sentence WHOLE, normalised through a `prose()` helper so the
  pin does not also encode where prettier wrapped.
- F4: the three added caller roots had no positive control; `src` alone clears
  any plausible file-count threshold. Now probes one path per root.
- F5: the job write-scan missed `pgDbWrite`, Kysely's `.updateTable(` and any
  helper indirection, and its negatives had no control proving they could fire.
  Now carries the repo's real write idioms, a positive control per idiom, and a
  pin on the job's awaited calls so a write hidden behind a helper fails too.
- The tooltip anchor now asserts it was found, so a missing card reports as a
  missing card rather than as a copy mismatch via `slice(-1)`.

Four header docblocks described the previous design and were corrected with it —
in a change about stale claims, shipping stale comments is the same defect.
2026-09-19 14:01:40 -05:00
Zachary Lowden bf43398486 feat(app-blocks): count the fifth bridge silence — validator_rejected (#4977)
* feat(app-blocks): count the fifth bridge silence — validator_rejected

The receiving half of the App Blocks bridge's fifth drop path. The other four
are already counted on civitai_app_block_bridge_messages_total (#4946); this one
no code here can observe, because the SDK's validator runs in the iframe AFTER
this host has replied — from here the exchange completed and the dispatcher
already counted it `handled`. The block is the only witness, so it reports over
the new fire-and-forget BLOCK_MESSAGE_REJECTED and the shared dispatcher
translates that into outcome="validator_rejected".

It is the one of the five with a confirmed production incident: on 2026-09-18
custom-generators served "Couldn't load your kept images just now." from relist
until a human found it by hand, while civitai_app_block_renders_total read
result=ok, error_class=none throughout.

  - bridgeLabels: a sixth BRIDGE_MESSAGE_OUTCOMES value. The beacon's
    z.enum(BRIDGE_MESSAGE_OUTCOMES) and the client emitter both derive from that
    array, so nothing else on the wire path needed respelling.
  - usePostMessage: one branch, in the SHARED dispatcher above the subscriber
    lookup — the message is telemetry, not a feature either host implements, so
    it reaches no onMessage subscriber by design and a per-host handler would be
    the same predicate written twice.
  - hostHandlerParity: the new type as an N/A-for-every-host entry, because the
    parity test greps hosts for a registration that must not exist here.
  - the label-product arithmetic in three docblocks: (A+1) x 47 x 2 x 5 becomes
    (A+1) x 48 x 2 x 6, ~29k series per pod at 50 approved apps.

The `type` on the new outcome is the block->host REQUEST left hanging
(GET_IMAGES_BY_IDS), not the rejected reply (IMAGES_RESULT). Deliberate and
measured: boundBridgeMessageType bounds the label against INVENTORY, which holds
no *_RESULT key, so a reply type would clamp to 'other' and collapse every
rejection in the protocol onto one label. Pinned by a test that asserts both
halves of that fact.

The clamp runs at the extraction site rather than in the sink. Found by the new
browser test rather than by reasoning: onOutcome is a documented seam, so a value
pulled from an untrusted payload and bounded only by the default sink is bounded
by nothing a reader of the branch can see — the test's own sink observed the raw
`NOT_A_REAL_MESSAGE`.

Watched to fail first. Disabling the dispatcher branch turns 6 of the 7 new
browser tests red; the one that stays green is the negative control (a healthy
exchange reports no validator_rejected). Removing the sixth outcome value fails
exactly one unit test; removing the INVENTORY entry fails exactly one other.

Verified: pnpm typecheck 0 errors (59s); vitest unit over src/components/AppBlocks
+ src/tests/api/track + src/server/metrics + src/server/schema 110 files / 1820
tests; test:lint-rules 46 files / 633 tests; component (chromium)
usePostMessageOutcomes.browser.test.tsx 15 tests.

Pairs with civitai/civitai-app-starters#317, which emits it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): round-0 audit fixes — six wrong or stale counts, three false rationales

Net -29 lines. No behaviour change except the one noted below; everything else is
prose that did not survive its own first extension. Each item's evidence was
re-verified before acting.

ARITHMETIC, re-derived rather than adjusted:
  - bridgeLabels.ts said this sixth outcome grew the label product "BY 20% —
    (approved apps + 1) x 47 x 2 x 6". Both halves wrong: 20% is the outcome axis
    alone and the type axis grew too, and the `x 47` omits the `'other'` slot the
    other two sites include. Re-derived at 50 approved apps: 51 x 48 x 2 x 6 =
    29,376 against 51 x 47 x 2 x 5 = 23,970, i.e. +22.6%. Also recorded that the
    product is a CEILING, not allocated heap — nothing pre-initialises the label
    space, so the sixth value costs zero series until a rejection occurs.
  - block-message.ts said "roughly 9x the existing renders_total product". The
    only renders_total figure in the repo is ~2,040, so it is ~14x. (The base said
    "7x" against 11.75x, so this one was already wrong before this branch — but it
    was rewritten rather than re-derived, which is the same defect.)

STALE COUNTS the change should have touched and did not:
  - bridgeLabels.ts "46-key INVENTORY" -> 47-key
  - bridgeLabels.ts "wrong for THREE of the five outcomes" -> FOUR of the six
  - bridgeMessageBeacon.ts "three of the five outcomes are reported above the
    bridge's inbound limiter" -> four of the six; validator_rejected is the fourth,
    which this branch's own comment already said
  - usePostMessageOutcomes.browser.test.tsx header "The four DISPATCHER outcomes …
    The fifth, no_token" -> five of the six are dispatcher-side now

FALSE RATIONALES — each would have led a reader to the wrong conclusion:
  - usePostMessageOutcomes.browser.test.tsx said "`report` -> `recordBridgeMessage`
    -> `boundBridgeMessageType` does the clamping; this pins that the branch routes
    through it". That is the PRE-correction design: these tests supply their own
    `onOutcome`, so `recordBridgeMessage` never runs and the clamp under test is the
    branch's own. A reader following the old comment would conclude the branch's
    clamp is dead code, delete it, redden four rows, and read the failure as "the
    test is wrong".
  - bridgeTelemetry.test.ts justified the new INVENTORY entry by the dispatcher's
    `no_handler` bookkeeping — a path this same change makes UNREACHABLE, since the
    new branch returns above it. The entry's real and only current purpose is
    hostHandlerParity's one-directional compile-time gate, i.e. it is what lets this
    repo bump @civitai/app-sdk past the version adding the message. Also recorded
    the cost of keeping it: boundBridgeMessageType now passes
    'BLOCK_MESSAGE_REJECTED' through for a forged POST instead of clamping it.
  - bridgeLabels.ts called this "the fifth silence". Retracted: the SDK's own
    handleMessage still drops silently and uncounted on an origin mismatch, on a
    malformed envelope, and on a well-formed reply whose requestId matches no
    pending request. This value covers the validator path only.

DELETED:
  - 10 of the 37 comment lines in usePostMessage.ts's new branch. Five of its six
    paragraphs restated bridgeLabels.ts, and the duplication had ALREADY drifted
    from the original inside this same branch — which is the defect the six counts
    above are. It now points at bridgeLabels.ts for the reading rules and keeps only
    what is specific to the branch.
  - one of the four clamp test.each rows. `{ type: 42 }` and `{}` reach the same
    `typeof !== 'string'` arm; each row costs a full renderWithProviders + iframe
    mount in chromium. `undefined` is kept — it is the only row exercising the `?.`.

Re-verified after the changes, since an audit fix resets the gate: pnpm typecheck
0 errors; vitest unit over src/components/AppBlocks + src/tests/api/track +
src/server/metrics + src/server/schema 1820 tests; component (chromium) 14 tests
(was 15 — one row removed). Mutation matrix re-run: disabling the dispatcher branch
reddens 5 of the 6 new tests, the survivor being the negative control; removing the
extraction-site clamp reddens exactly 1, printing the leaked value.

Pairs with civitai/civitai-app-starters#317.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): the HELP text asserted an SDK emit budget the emitter deleted

Round-1 audit findings. No 🔴; every item is a false or stale claim on a surface
an operator reads.

🟡 THE SHIPPED HELP STRING WAS FALSE, on the axis this whole change exists to get
right. It told every scrape "the SDK budgets its reports at 30 per 10s per
transport so a sustained break UNDERCOUNTS — read it as which types and when it
started, never as a total." The emitting half deleted that budget in
civitai-app-starters#317 (its own docblock: "NO EMIT BUDGET, AND THE ONE AN
EARLIER REVISION CARRIED WAS JUSTIFIED BY A FALSEHOOD"), so the reading
instruction inverted the truth: an operator seeing >30 per 10s per transport
would conclude the count is structurally impossible and chase a forged beacon
instead of the rejection loop that produced it. Corrected in all three places it
was stated — the HELP string, bridgeLabels, and the flood test's comment.

🟡 AND THE CAVEAT THAT SHOULD HAVE BEEN THERE INSTEAD: a zero is not evidence of
health. The emitter ships inside each block's OWN bundle — every app pins
@civitai/blocks-react itself — so the series stays at zero until every app has
been rebuilt AND redeployed against a version carrying it, not merely until the
package publishes. A flat-zero diagnostic read as health is the exact failure
this outcome exists to end, and nothing said so.

🟡 THE BRIDGE_MESSAGE_COUNT_MAX DOCBLOCK'S HEADLINE OUTRAN ITS OWN ENUMERATION.
The previous commit changed "wrong for THREE of the five outcomes" to "FOUR of
the six" without touching the bullets below it, which still accounted for five,
or the prose after them, which still said "those three" and "the other three".
validator_rejected — the outcome this PR adds, reported above the limiter, and
whose emitter now has no cap either — appeared nowhere in the list a reader
consults to learn which outcomes can drive a key to the 100,000 clamp. At base
that docblock was internally consistent; this branch made it inconsistent, so it
is a regression in the artefact and not inherited rot.

🟡 The dispatched message type was a bare literal in the `if`, with no
compile-time link to the protocol — and the tests use the same literal, so an SDK
rename would silently disable the branch, return the series to zero, and falsify
hostHandlerParity's entry with nothing red anywhere. Now a constant bound with
`satisfies keyof typeof INVENTORY`; INVENTORY was already in this module's import
graph, so the binding costs nothing. It catches a rename that reaches the
inventory, not one that has only happened upstream — the upstream half is
hostHandlerParity's own gate, and the comment says so rather than overclaiming.

🟢 An in-code comment still said "deleting it reddens these four rows" above a
three-row test.each (the previous commit removed a row), and a byte figure I had
edited from ~6.7 to ~6.8 KB was never measured — replaced with "several KB"
rather than inventing precision.

Verified: pnpm typecheck 0 errors; vitest unit over src/components/AppBlocks +
src/tests/api/track + src/server/metrics + src/server/schema 1820 tests;
component (chromium) 14 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): a zero is per-APP, and the satisfies binding is narrower than it claimed

Round-2 delta findings. No 🔴; every one is an assertion on a surface an operator
or a maintainer reads.

🟡 THE ZERO CAVEAT STATED FLEET GRANULARITY WHERE THE MECHANISM IS PER-APP, and so
licensed the inference it was added to block. The counter is labelled
`app_block_id`; the series does NOT stay at zero until every app is rebuilt — it
goes non-zero the moment the FIRST rebuilt app hits a rejection, while every
un-rebuilt app's slice sits at a zero that means nothing. As written, an operator
seeing three apps report would conclude the fleet had picked it up and read the
other zeros as health — the flat-zero-read-as-health failure, one level up. Both
the shipped HELP string and bridgeLabels now say: read it WITH `app_block_id`;
for a given app a zero is "no rejections" OR "this app has not shipped a carrying
blocks-react", and another app reporting does not settle it.

🟡 THE `satisfies keyof typeof INVENTORY` BINDING IS NARROWER THAN "an SDK rename
is a type error", which is what its own comment and the PR body both claimed. It
fires only on a rename that reaches the inventory AND DROPS THE OLD KEY — and
hostHandlerParity's coverage gate is one-directional BY DESIGN, so the documented,
gate-satisfying way to track an upstream rename is to ADD the new key and leave
the old one (it carries three such keys today). In exactly that state the binding
stays green, the branch never fires, and validator_rejected returns to a permanent
zero the HELP now tells a reader to interpret as a rollout gap — the
silent-dead-branch failure the binding was added to end, surviving it.

No better guard was reached for, deliberately. The comment now states the real
boundary and says plainly that the add-and-keep window is OPEN rather than
supplying a fresh justification for a guard that does not cover it. The binding
still earns its place: it catches an outright key deletion and a typo either side.

🟡 "Two consequences" over three bullets — round 1's finding in the
BRIDGE_MESSAGE_COUNT_MAX docblock (headline outran its own list), re-made by the
commit that fixed it there, in the docblock next door. Now three, and the bullet
says so.

🟢 "The SDK shipped a 30-per-10s emit budget and then DELETED it" asserted a
release history that never happened: the budget existed only in an unmerged
revision of the sibling PR and reached no published package. Read as release
history it tells an operator that older bundles in the field DO undercount, which
is the inverted reading this whole fix removes.

🟢 An in-code comment's referent ("every row below" — the table is above).

Also corrected in the PR body: "~22 keys ahead" is the published union's SIZE, not
the gap — INVENTORY has 47 keys against 22 published members, so the gap is 25;
and the clamp mutation row said "exactly 1 red" where the ISOLATED mutant reddens
3. The earlier number was measured against a weaker mutant that removed the clamp
AND kept a hardcoded 'other' fallback — two changes, not the narrowest expression
that can be wrong. Re-measured with the clamp call alone deleted: 3.

Verified: pnpm typecheck 0 errors; unit over src/components/AppBlocks +
src/tests/api/track + src/server/metrics + src/server/schema 1820; component
(chromium) 14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): 25 of 47 keys run ahead, not three — and say what type='other' means here

Round-3 delta findings. All prose; zero executable change beyond one import
becoming type-only.

🟡 THE BOUNDARY STATEMENT'S OWN COUNT WAS WRONG BY 8x AND CONTRADICTED THE SAME
COMMIT'S PR-BODY EDIT. The docblock said the inventory "today carries three" keys
ahead of the published dist. Measured: 47 INVENTORY keys against the 22 members
the installed @civitai/app-sdk@0.14.0 block->host union declares, so 25 are ahead
— while that same commit edited the PR body to say 25. One commit asserted both
numbers for one quantity, and the wrong one was the one a maintainer meets first.

It is load-bearing, not decoration: the docblock's argument is that add-and-keep
is the documented way to track a rename, the window is open, and no better guard
was reached for. A reader told the window is a three-instance curiosity weighs
that differently from one told it is the state of 25 of 47 keys — i.e. the norm.

⚠️ And the "three" was inherited from hostHandlerParity.ts:56-58's parenthetical,
which is itself stale: it names CANCEL_WORKFLOW, REQUEST_SIGN_IN and
REQUEST_CONSENT as the ahead-of-published keys and ALL THREE are present in
0.14.0, so zero of them is ahead. The docblock now says to measure rather than
read that list. (Correcting the parenthetical is base rot, not this branch's.)

🟡 WHAT type='other' MEANS ON THIS OUTCOME WAS NOWHERE, and both surfaces implied
the opposite. The HELP said the type "is the block->host REQUEST left hanging" and
separately that an unknown type clamps to 'other'. Against the emitter's current
head, 'other' is also what it sends for a rejected host PUSH (nothing was awaiting
it, so nothing hangs) and for a reply it could not attribute. So an operator
applying those two sentences to a validator_rejected{type="other"} row concludes
an unrecognised request is hanging to a 30s timeout — both halves wrong, and the
two cases share the bucket. Stated on the HELP string and in bridgeLabels: 'other'
is the one value on this outcome you must NOT read as "a request is hanging".

🟡 The docblock also overstated the consequence in the SAFE direction, which is
still wrong: a renamed-and-kept key does not silently zero the signal, it files it
under no_handler with the NEW type (unclamped, since that would be an INVENTORY
key). Harder to notice than an absence, not easier.

🟢 The INVENTORY import is used only in `typeof INVENTORY`, so
@typescript-eslint/consistent-type-imports (error severity) reports it. It blocks
nothing — lint.yml gates on ADDED files and this one is modified, and the
in-cluster pr-check pipeline runs typecheck and tests, not eslint — but it is a
one-token fix and the annotation would have sat on the diff. Now `import type`.

Verified: pnpm typecheck 0 errors; unit over src/components/AppBlocks +
src/tests/api/track + src/server/metrics + src/server/schema 1820; component
(chromium) 14. The 25-vs-3 count was re-derived independently by enumerating both
sets, not taken from the audit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): type='other' is OVERLOADED four ways, and the clause said two

Round-4 delta finding, and the last one on this PR — see the closing note.

🟡 The `type='other'` clause added last round asserted an exclusivity the same PR's
code contradicts, and it contradicted the line two above it. It said 'other' "DOES
NOT MEAN an unrecognised type", directly below the sentence explaining the clamp
that produces exactly that case. FOUR things reach the bucket, not two:

  (a) the SDK rejected a host PUSH — nothing was awaiting it, so nothing hangs;
  (b) the SDK could not attribute the reply to one of its pending requests;
  (c) the block named a type this host's INVENTORY does not declare, or named
      nothing — usePostMessage's own boundBridgeMessageType clamp, which a browser
      test in this very PR exercises with NOT_A_REAL_MESSAGE;
  (d) the SDK clamped an undeclared requestType while a request genuinely DOES hang.

So 'other' cannot be read in EITHER direction: not as "a request is hanging" (a and
c may hang nothing) and not as "nothing is hanging" (b and d may). The previous
wording gave an operator a protocol diagnosis — "a rejected push, or an
unattributable reply" — for what may be a real exchange the emitter mislabelled.

⚠️ (c) is reachable today only for a malformed or undeclared value: the SDK's
47-entry type array and this repo's 47-key INVENTORY were measured EQUAL at both
current heads, so no legitimate protocol type clamps. Recorded as a measurement
across two moving repos, not as an invariant — the docblock 20 lines above is
precisely about drift in that relationship.

NOT FIXED, deliberately, and named so it is visible rather than absent:
hostHandlerParity.ts:685-686 carries a SECOND copy of the stale ahead-of-published
parenthetical (CANCEL_WORKFLOW, REQUEST_SIGN_IN, REQUEST_CONSENT "today" — all
three are in 0.14.0, so zero of them is ahead), and last round's warning points at
only the first copy. Both are pre-existing `main` text that no commit on this
branch touches. Correcting them is a one-line edit somebody should make; widening
this PR to do it would put an unrelated doc change in a bridge-telemetry diff.

🔴 CLOSING THIS PR'S AUDIT LADDER HERE, on a stated criterion rather than a feeling.
Rounds 3 and 4 both changed ZERO executable lines on this PR — round 3's only
non-comment edit was an import becoming type-only (erased at build), round 4's is
comment text and one HELP literal. The attribution gate's two-consecutive-
zero-payload condition cannot fire mechanically because comments in payload files
count as payload lines, but the condition it exists to detect is met: the rounds are
auditing the ladder's own prose, not the PR. The code has been unchanged and green
since 37a2ef5b34. A fifth round over an adjective is the cost the gate exists to
avoid, and the round-4 auditor recommended the same independently.

Verified: pnpm typecheck 0 errors; unit over src/components/AppBlocks +
src/tests/api/track + src/server/metrics + src/server/schema 1820; component
(chromium) 14.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 14:00:03 -05:00
Zachary Lowden 4642bbfc79 fix(search): stop QuickSearchDropdown's index selector deselecting itself (#4975)
Mantine's `Select` is deselectable by default, so clicking the currently
selected option clears it and hands `null` to `onChange`. `QuickSearchDropdown`
passes that through to `handleTargetChange`, which falls back to `models` — and
the caller goes on reading the picked entity as whatever type its
`supportedIndexes` named, while the dropdown is now searching a different index.

This is most visible where the caller offers a single index, because then the
only clickable option is the one already selected, and a stray click is enough.
`CosmeticShopItemUpsertForm` is that case: `supportedIndexes={['users']}`, under
a "Funds Distribution" label reading "The cost of this item will be split evenly
among the selected users", writing what is picked into `meta.paidToUserIds`. So
the index the search is pointed at is not a display detail there.

`allowDeselect={false}` is the Mantine-level fix: the selector stops emitting
`null` at all. `AutocompleteSearch`'s equivalent selector already carries it.

Not tested here, said out loud rather than left as a silent omission: the
behaviour needs a rendered Mantine `Select` to observe, which is the browser
tier, and `pnpm run test:component` does not run on the machine this was written
on (its pinned Playwright store carries a different chromium-headless-shell
build than the runner asks for, and reports "no tests" rather than failing). A
check on the prop's spelling in the source would be an invariant guard, not
regression coverage — it would be green with or without the defect present in
any other shape.
2026-09-19 13:24:56 -05:00
Zachary Lowden 854595f7d3 fix(search): rebuild the dropdown search provider when its index changes, and carry the typed text across (#4953)
* fix(search): drop a dropdown search whose filters target the previous index

The header autocomplete and the quick-search dropdown render
`<InstantSearch indexName={...}>` with no `key={indexName}`.
react-instantsearch-core calls `helper.setIndex(indexName).search()` in its
RENDER body, and `<InstantSearch>` renders before its children, so a target
switch fires a search while the helper still carries the previous target's
`<Configure filters>`. The models filter set then lands on another index and
the search backend answers 400 `invalid_search_filter`, which the resilient
client swallows into an empty dropdown. Measured in production RUM: hundreds
of such rejections a day, dominated by models-only attributes arriving at the
images index and by `poi` arriving at indexes no code path ever intends it for.

`SearchLayout` fixes this with `key={indexName}` and says so in a comment. The
dropdowns cannot copy that: remounting clears the query the user is typing.

So the request is rejected in the client instead. `withSearchFilterGuard`
validates each request's filter, facet and numeric-filter attributes against
what its target index declares in `src/server/search-index/filterable-attributes.ts`
and resolves a doomed request to the ordinary empty-result shape without
sending it. Valid requests in the same batch still go to the backend and keep
their position in the response. No UX change: a rejected request already
rendered empty.

It is not silent. A rejection still pushes a Faro RUM error, under its own type
(`SearchFilterAttributeError`) so a locally-rejected request stays tellable
apart from a backend-rejected one (`MeiliSearchQueryError`). Note that a
population which used to beacon under the old type now beacons under the new
one, since it no longer reaches the backend at all.

The guard covers exactly the leaks that name an attribute the new index cannot
filter on — the ones that produce a 400. When the previous target's attributes
are all declared on the new index the stale request is valid there and is sent,
missing whatever clauses that target never built; that direction returns 200,
appears in no error signal, and no attribute check can see it. The module
documents this rather than reading as though it closes the class.

Wiring: the three browser search clients were three hand-rolled compositions
with the same 18-line empty-query short-circuit copied into two of them and
absent from the third. They now come from one `createSearchClient` factory, and
each is exported from its own module so the wiring is reachable from a node
test. That is load-bearing for the tests, not tidying — while the clients were
built inside the `.tsx` files the only possible check was a grep of the
component source, and a source check cannot tell a guarded client that is USED
from one that is merely constructed.

Tests: every shipped client is exercised through its own export, asserting the
negative — the doomed request must not be SENT — plus a positive control that a
filter set built for the index it targets still is. With the guard bypassed at
the factory, those three tests fail on that assertion. A derived ledger
enumerates every module constructing a search client and requires each to be
either the guarded factory or an exclusion whose stated reason is asserted, so
it fails when the population grows and when an exclusion stops holding.

* fix(search): rebuild the dropdown search provider when its index changes

`SearchLayout` keys its `<InstantSearch>` on the index name and says why:
"Needs re-render. Otherwise the prev. index will screw up the app." The header
autocomplete and the quick-search dropdown never got that key, so they carry the
defect the comment describes.

react-instantsearch-core calls `helper.setIndex(indexName).search()` in its
RENDER body (`lib/useInstantSearchApi.js`), and the provider renders before its
children. So on a target switch the search fires against the NEW index while the
helper still holds the PREVIOUS target's `Configure` parameters — the children
that own `filters` have not re-rendered yet. Keying the provider makes React
build a fresh one instead, and the children mount their parameters onto it
before it searches.

This is additive to the request-level guard already on this branch, and it
covers a direction that guard structurally cannot. The guard drops a request
naming an attribute its target index does not declare. When the previous
target's attributes are all declared on the new index — `articles` and
`collections` are subsets of `models` — the stale parameter set is perfectly
valid there, so it is sent, and the clauses the new target builds only for
itself are simply missing from it. That answers 200 and appears in no error
signal. The key closes it at the source: there is no stale parameter set to
send.

The reason the dropdowns could not just copy `SearchLayout` is that remounting
clears the text the user is typing, which lives inside the provider's subtree.
So each root now holds that text in a ref ABOVE the keyed boundary and the
remounted input is seeded from it (`useCarriedSearchText`). Seeding is not only
cosmetic: a rebuilt helper reports an empty query, so the seeded text differs
from it, and that difference is what makes each component's existing "push the
text into the helper" effect fire again — the search is RE-RUN on the new index
rather than the input merely re-displaying the old text. An empty carrier falls
back to the helper's own query, which is what a first mount did before.

Every write to that text goes through the setter the hook returns, so the
carrier can never hold a value the input no longer shows.

Tests, in `src/components/Search/__tests__/dropdown-index-remount.test.ts`:

- A ledger of every `<InstantSearch>` root in `src/`, derived from the tree so it
  fails when the population grows or shrinks. Each keyed root must key on the
  very expression it passes as `indexName` — not merely carry some key, which
  could disagree with the index. `CollectionSelectModal` is the one exclusion and
  its stated reason is asserted, not taken on trust: its index is a fixed member
  of `searchIndexMap`, so there is no switch to survive.
- The carry, asserted structurally on both dropdown roots: the ref is declared
  above the provider, threaded into the content component, and read through the
  hook — and the `useState(query)` shape it replaced, which comes back empty on
  a remount, is banned.
- The hook's behaviour, exercised in the node tier against a real React remount:
  text typed before a key change survives it, and comes back differing from the
  rebuilt helper's empty query. The negative control is the same tree seeded the
  old way, which loses the text — without it, a harness that silently never
  remounted would pass every other assertion while asserting nothing.

Matrix: the four structural tests are RED at `bbeffcf71e` (no `key` prop, no
carrier) and green at HEAD. The behavioural and ledger tests are new-behaviour
guards, not regression tests, and are green at both. Three mutants of the hook
were each killed by their own assertion, with the source restored by digest
after each and a green re-run after the sweep.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(search): keep the chosen category when the search provider is rebuilt

Review follow-up to 19adaeb56e. That commit keyed both dropdown
`<InstantSearch>` roots on their index, which is what stops a search firing
with the previous index's parameters — and the remount it introduces reached
two pieces of state nobody had to think about before, because nothing in these
trees had ever unmounted.

1. The header category selector stopped working, on every page.

   `AutocompleteSearchContentInner` carried

       useEffect(() => {
         if (indexNameProp !== searchTarget) onTargetChange(searchTarget);
       }, [searchTarget]);

   where `searchTarget` comes from `usePathname()`, not from the pick. With no
   remount that effect ran only on navigation. Once the provider is keyed,
   choosing a category remounts the subtree — and a mount runs every effect
   regardless of its dependency array — so the effect read the URL's section,
   found it different from the pick, and put the target back. Measured against
   a real react-instantsearch tree: user on /models picking Images settled on
   `models`, via two mounts and two searches, one of them against an index the
   user was immediately bounced off. `searchTarget` is `models` on every page
   whose first path segment is not a search target, so this was not an edge.

   The sync now lives in `AutocompleteSearch`, above the keyed boundary, for
   the same reason the typed-text carrier does: it has to observe navigation
   without being restarted by a target switch.

2. Both target selectors were uncontrolled, so their displayed label was state
   inside the remounting subtree. A switch reset the label to the default while
   the search really had moved — a control that lies about what it is
   searching. Both now read the target they are searching.

   `QuickSearchDropdown` also defaulted its target to `models` while its
   selector offered `supportedIndexes`, so a caller passing `['users']` and no
   `startingIndex` showed "Users" over a models search. Invisible while the
   selector held its own value; an empty selector once it is controlled. The
   default is now the first supported index, which makes the two agree at the
   source.

3. `AutocompleteSearch`'s refine effect returns early on `searchErrorState`,
   which reads a module-level store and therefore SURVIVES the remount. It was
   not in the effect's dependencies, so a tree that remounted while search was
   unavailable restored the typed text, returned early, and never refined when
   the flag cleared — a populated input over an empty helper query until the
   next keystroke. Added to the dependencies.

   Both components' refine decisions now go through one `shouldRefineSearchQuery`
   predicate rather than two hand-written conditions that had already drifted
   apart.

Also: the guard test added earlier on this branch stated, as the reason the
guard exists, that the dropdown roots "cannot" be keyed because remounting
would clear the typed query. This branch falsifies that, and its exclusion rule
("has no key={indexName}") no longer discriminates now that every root is keyed.
Comments only — no assertion changed.

Tests: 22 in `dropdown-index-remount.test.ts`, 7 of them RED with the two
components at 19adaeb56e's parent and green at HEAD. The harness now models the
helper as per-mount state and drives the shipped predicate, so the claim that a
remount RE-RUNS the search is observed (a second refine with the carried text)
rather than asserted by a test name. Added: a negative control that the carrier
is per-instance rather than module-scope, and the blocked/recovered pair.

Mutation sweep, source restored by digest after each and a green re-run after
the batch: 10 mutants, 10 killed, each by an assertion naming its own behaviour.
Two of them — pinning either root's index to a constant — SURVIVED the first
round, because the ledger checked the expression in the tag while both roots
hoist it into a local; the check now follows a bare identifier to its
declaration, and both then die. That pair is the reason this commit's ledger is
worth more than the previous one's.

Not verified: the browser tier does not run on the authoring host, so the real
click path is still unexercised. Finding 1 was reproduced by a subagent against
a real react-instantsearch tree in a scratch harness, not by a test in this
repo; what ships here for it is a structural guard.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(search): keep the target selector inside the set it offers

Round-2 review of cf5c69e235. Making both target selectors controlled closed a
label desync and opened a narrower one, and the deselect path the controlled
value exposed turns out to reach a payout field.

1. A controlled `<Select>` whose value is not among its options keeps showing
   the PREVIOUS option's label. Mantine's value→label sync runs only when the
   value resolves to an option (`Select.mjs`: the `[value, selectedOption]`
   effect takes neither branch otherwise) and the uncontrolled fallback that
   used to clear it is skipped once `value` is passed. The header search reaches
   that state without any user action: its target follows the URL section, and a
   section whose feature flag is off is filtered out of the options — so on
   `/images` with image search disabled the selector read "Models" while the
   provider searched the images index. Uncontrolled it read blank. Both
   selectors now pass `null` when the target is not an offered option, which is
   what blanks it; the offered set is hoisted so the value is clamped to the
   same list the options are built from.

2. `QuickSearchDropdown`'s selector did not set `allowDeselect`, and Mantine
   defaults it to `true` — so a single-option selector is deselectable, and the
   deselect hands `null` to the change handler, which fell back to `models`.
   Callers read the picked entity as the type their `supportedIndexes` names;
   `CosmeticShopItemUpsertForm` passes `['users']` with the selector visible and
   writes the picked id into `meta.paidToUserIds`, which
   `cosmetic-shop.service.ts` splits a price across. `allowDeselect={false}`,
   and the fallback now goes to the first supported index rather than to
   `models`. Pre-existing on both counts; the previous commit fixed the
   initial-state half of the same inconsistency and left the runtime half.

3. Corrections to claims, no behaviour change. The `supportedIndexes` fallback
   comment described an observed defect; enumerating all ten call sites shows
   every one either passes `startingIndex` or supports `models` first, so it is
   a forward guard and now says so. `shouldRefineSearchQuery`'s docblock
   required every input to appear in the calling effect's dependency array,
   which `selectedItem` does not — the rule is real only for a source that
   OUTLIVES the remount, and the sentence now says that and names both cases.

Tests: 22, unchanged in count, 7 still RED with the two components at
bbeffcf71e. Five mutants that survived round 2 now die:

  - the URL-follow sync left in place AND re-added inside the subtree (the
    consolidation that forgets to delete one copy — this restores the reverted
    category pick with every other assertion satisfied)
  - its dependency array emptied, so it stops following navigation
  - `searchErrorState` dropped from the predicate ARGUMENT while left in the
    dependency array, which the dep-array check alone cannot see
  - the selector value unclamped
  - `allowDeselect` removed

Two spellings named in review are accepted as still walkable: an index written
`searchIndexMap['models']` or as an imported constant satisfies the
constant-index check, because a source-text guard cannot follow either. The
comment says what these guards pin — spelling and file order — rather than
implying tree position.

Still not verified: the browser tier does not run on the authoring host, so the
real click path remains unexercised end to end, and finding 1's stale-label
behaviour was established from Mantine's source rather than by rendering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): make the selector guards constrain the thing that can go wrong

Round-3 review of a4ca0d1ff9. The safety lane came back clean; the test lane
found that the two production behaviours added that round — the value clamp and
the `enabledTargets` hoist — shipped with assertions that did not pin the part
that can actually break. Test-only, plus one type error.

  - The clamp assertion was RECEIVER-AGNOSTIC: it matched
    `… value === indexNameProp) ? indexNameProp : null` whatever `.some()` was
    called on. Clamping against the UNFILTERED list restores the exact stale-
    label defect the clamp exists to prevent, and every test stayed green. Now
    pinned to `enabledTargets.some(…)`.
  - Nothing asserted `data={enabledTargets}`, so the offered list and the list
    the value is clamped against could diverge — the one invariant the hoist's
    own comment states. Now asserted, in both files.
  - `allowDeselect={false}` was pinned on `QuickSearchDropdown` only.
    `AutocompleteSearch` is the copy where it matters more: its change handler
    casts the `null` a deselect produces straight through with no fallback, so
    `searchIndexMap[null]` reaches the provider as an undefined index. Pinning
    only the sibling makes the natural "these two selectors duplicate props"
    tidy-up delete the unguarded one. Now asserted on both.
  - The "exactly one writer" count and the position check read RAW source, so a
    comment naming `setTargetIndex(searchTarget)` — including one written to
    warn against the doubled-writer mutation the count exists to catch — turned
    the test red. Same prose-satisfies-a-token hazard the refine-deps check was
    fixed for one round earlier, running in the mirror direction. All the
    counting and locating checks now strip comments first, through one shared
    helper.
  - The follows-nav regex required the dependency array to be EXACTLY
    `[searchTarget]` and the write to be the last statement in the effect. A
    legitimate added dependency, or the `eslint-disable-next-line` line this
    same file already uses twice, went red for no defect. It now requires the
    array to CONTAIN `searchTarget`.
  - `ReturnType<typeof readdirSync<{ withFileTypes: true }>>` is not valid
    TypeScript — `readdirSync` is not generic in this `@types/node`. Nothing
    caught it: `tsconfig.json` excludes `src/**/__tests__/**`, so the repo
    typecheck cannot see it, and esbuild strips types for vitest. `Dirent[]`.

Sweep: 5 new mutants, 5 killed — the clamp pointed at the unfiltered list in
either component, `data` reverted to the unfiltered list, `allowDeselect`
removed, and `allowDeselect` "satisfied" by commenting it out. Plus two
FALSE-POSITIVE controls that must stay green and do: a comment naming
`setTargetIndex(searchTarget)`, and a legitimate extra dependency with an
eslint-disable line above the array.

Matrix unchanged: 22 at HEAD, 7 red with both components at bbeffcf71e.
`tsc -p tsconfig.tests.json` is now clean for these files as well as the repo
typecheck, which does not cover them.

Accepted and left open, with the reason in the comments rather than a guard:
an index written `searchIndexMap['models']` or as an imported constant still
satisfies the constant-index check, because no source-text guard can follow
either binding. The full-call-expression and clamp assertions are pinned to
spelling, so a rename or a prettier wrap reddens them for a non-defect; both
sit near the 100-column limit today.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(search): restore the two components a3e8d80062 reverted by accident

a3e8d80062 was meant to be test-only. It also rolled `AutocompleteSearch.tsx`
and `QuickSearchDropdown.tsx` back to bbeffcf71e — no `key` on either
`<InstantSearch>`, no typed-text carrier, no selector clamp — while leaving the
tests that assert all of it in place.

Cause, recorded because it is not obvious and it is a trap for anyone measuring
a red/green matrix the way this branch has been: `git checkout <ref> -- <path>`
writes the INDEX as well as the working tree. The matrix run checked the two
components out at the base, and the restore afterwards copied the files back
into the working tree only. The index still held the base blobs, `git commit`
commits the index, and `git add` of the two test files did not touch them. The
`MM` in `git status` was the tell and it was not read.

This restores both files to exactly their a4ca0d1ff9 content — verified by
`git diff a4ca0d1ff9 -- <both paths>` being empty, and byte-identical to the
copies the round-3 mutation sweep was run against.

No behaviour is intended to change from a4ca0d1ff9. Every test that a3e8d80062
added still passes; the seven that go red with the components at the base are
red for that state and green again here, which is what makes the accident
visible rather than silent.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): pin the operators, not just the calls

Round-4 review. The round-3 tightenings were REPLACEMENTS rather than additions,
and each one dropped the half of the expression that says what is DONE with the
answer. Three one-character or one-clause mutants killed the feature outright
with the whole suite green.

  - The selector clamp was asserted as its predicate only, so negating the
    condition — show the target only when it is NOT an offered option — passed
    everything, as did replacing the `null` branch with a first-option fallback,
    which reinstates exactly the wrong-label lie the clamp exists to prevent.
    The whole `value={…}` expression is now pinned, in one shared constant so
    the two components cannot drift apart.
  - The refine gate was asserted as the call, never the leading `!`. Dropping it
    inverts both effects — they return early exactly when they should refine —
    which is Part 2's entire mechanism, dead, with nothing red. Nothing else in
    the repo sees it either: the browser specs stub `useSearchBox` or mock the
    component wholesale. Both `if (…)` statements are now pinned in full.
  - The carrier was asserted as `useCarriedSearchText(carriedSearchText, query)`,
    which a call whose RESULT is discarded still satisfies — call it, then seed
    from `useState(query)` beside it, and the typed text is per-mount state again.
    The destructuring is now part of the assertion.

Sweep: 6 mutants, 6 killed, each on its own assertion — clamp negated, clamp
given a first-option fallback, refine negation dropped in either component,
carrier result discarded in either component.

Also: the `stripComments` helper now runs on every check that counts or locates
a token, including the two it had been left off (the carrier position check and
the ledger's membership test); the follows-nav window can no longer skip over an
intervening bracket pair to find a different dependency array.

Two things are now said out loud rather than left implied. `allowDeselect={false}`
on `AutocompleteSearch` predates this PR — it is an INVARIANT GUARD, not
regression coverage, and it is labelled as one. And `QuickSearchDropdown`'s
`supportedIndexes`-aware fallback has no test, deliberately: no caller reaches it,
so a test for it would also be an invariant guard.

Matrix: 23 at HEAD, 8 red with both components at bbeffcf71e (the new refine-gate
test is the eighth).

🔴 The base measurement used `git show <ref>:<path> > <path>`, which writes the
working tree ONLY. `git checkout <ref> -- <path>` writes the INDEX as well, which
is how the previous round's measurement got committed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): pin the wiring, and state the tracking rule as a shape

Round-5 review. Three gaps, all in the same direction: the guards pinned what
is DECLARED and never what is USED.

  - The carrier assertion pinned the declaration, so `value={search}` on the
    input could become `value={query}` in either dropdown and the whole suite
    stayed green — after a remount the input renders the fresh helper's empty
    query while the carrier holds the typed text, which is the defect this PR
    exists to fix. The input wiring is now pinned on both sides.
  - The "index must track something" check enumerated constant SPELLINGS to
    reject. It caught `searchIndexMap.models` and a string literal and let
    `searchIndexMap['models']` and an imported `IMAGES_SEARCH_INDEX` through —
    both one-token edits, both leaving `key` and `indexName` in agreement while
    the selector stops switching index at all. It now states what a tracking
    index IS: a `searchIndexMap[targetIndex]` subscript, asserted per dropdown.

    🔴 The generic identifier resolver that check used is DELETED rather than
    widened. Scoped to the two dropdowns it is correct; applied generically it
    bound `SearchLayout`'s `indexName` — a PROP — to an unrelated
    `const indexName = Object.keys(uiState)?.[0]` elsewhere in that file, and
    went red on a root that has nothing wrong with it. Caught by running it.
  - `AutocompleteSearch`'s refine gate is wrapped across two lines by prettier,
    so pinning the `if (…)` alone left its `return` unpinned; neutering the
    consequent refined during an outage with the gate correctly spelled. Matched
    across the break now.

Also: the availability test no longer restates what the refine-gate test already
subsumes — one change should redden one test — and the clamp constant carries
back the brittleness caveat it lost, now with the measurement: the pinned line is
EXACTLY 100 characters in both files against `printWidth: 100`, so one more level
of indentation reddens it on formatting alone.

One new test, labelled INVARIANT because it pins a case production cannot reach:
carried text outranking a NON-EMPTY helper query. Both non-empty at once cannot
happen — a rebuilt helper always reports `''` — which is exactly why it is the
only thing that can see the hook's own `seedCarriedSearchText` call with its
arguments swapped. That mutant survived every other test in the file.

Sweep: 8 mutants, 8 killed, each on its own assertion — the input reading the
helper query in either dropdown, the index pinned to an imported constant, to a
bracket literal and to a dot constant, the refine consequent neutered, and two
against the hook itself (the setter not writing the carrier; the seed arguments
swapped). The previous rounds' sweeps had no hook mutants at all, so 12 of the
tests had never been watched go red for a defect in the code they cover.

Matrix: 24 at HEAD, 8 red with both components at bbeffcf71e. The base half says
nothing about the 12 hook-tier tests — the hook does not exist at that commit, so
it is held at HEAD for the measurement; the sweep above is what covers those.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): pin the category pick, and put back what SearchLayout lost

Round-6 review. Two findings, both introduced by earlier rounds of this same
ladder, both test-side.

1. Nothing asserted that PICKING a category reaches the state the index is
   derived from. The suite pinned the selector's value, its options and its
   deselect behaviour, and the input's `value={search}` / `setSearch(value)` —
   and left the handler between them unpinned. Five mutants were green:
   neutering either selector's `onChange`, emptying either `handleTargetChange`,
   and neutering the `onTargetChange`/`onIndexNameChange` prop. Manual category
   switching is dead in all five, and `AutocompleteSearch` still LOOKS alive
   because its URL-follow effect keeps calling `setTargetIndex` — only the
   user's own pick stops working. The exactly-one-writer count cannot see it:
   `onTargetChange(v as TKey)` is not that pattern.

   This is the same hole as the input wiring one round earlier, one level up.
   Both handler hops are pinned now, in a test named for them.

2. Deleting the generic identifier resolver last round left `SearchLayout`
   UNGUARDED on its index expression. It was the only root that check reached
   which the per-dropdown replacement does not, so pinning both its `key` and
   its `indexName` to the same constant went from red to green — two identical
   constants satisfy `key === indexName`, and every search page would then
   search one index. Closed by asserting the prop expression is not itself a
   constant, which is prop-scoped and therefore cannot re-create the false
   positive the resolver had (it bound `SearchLayout`'s `indexName` to an
   unrelated `const indexName = Object.keys(uiState)?.[0]`).

   Same check also closes the dropdown variant the resolver used to catch:
   pinning the JSX to a constant while leaving the tracking `const` in place.

Also: `INDEX_TRACKS_TARGET` moved out of the carrier test into its own, so that
mutation class no longer reports under a title about carrying typed text, and
its `=` now tolerates a prettier wrap the way the clamp constant documents.

Sweep: 7 mutants, 7 killed, each on its own assertion — the `SearchLayout`
constant pin, the QuickSearchDropdown JSX constant pin, and all five selector
wiring mutants.

Matrix: 26 at HEAD, 10 red with both components at bbeffcf71e.

NOT closed, and named rather than left silent: `stripComments` itself has no
killing mutation — deleting either half leaves the suite green, because only a
composite (comment-out the code AND disable the matching strip) can show it.
A test for it would be a test of the test file, breakable only by an edit to
that same file, so it is declined rather than forgotten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): close the middle hop, and stop rejecting correct code

Round-7 review. Two findings, and a false positive the fix for the second one
exposed.

1. `AutocompleteSearch`'s `handleTargetChange` body was unpinned. The test added
   last round pins both ends of the chain on that component — the selector's
   `onChange`, and the `onTargetChange` prop — and stopped. Deleting
   `setTargetIndex(value)` between them left all 26 green while picking a
   category in the header search never writes the target: the provider never
   re-keys and the controlled selector snaps its label back. The sibling was
   covered only because `setTargetIndex(value ?? fallbackIndex)` happened to be
   on the list for a different reason, which is what made the asymmetry hard to
   see. Pinned now, and the test says which of its assertions are invariant
   guards — four of the five spellings are unchanged from the PR base, so its
   red-at-base comes from the fallback line alone, not from the claim in its
   title.

2. The `SearchLayout` guard added last round enumerated constant SPELLINGS to
   reject — the exact hole the file's own docblock warns about two paragraphs
   earlier. An imported `MODELS_SEARCH_INDEX` walked through it, and every
   search page would then search one index. Restated positively: that root's
   index must be the parameter the component destructures.

   🔴 It also REJECTED CORRECT CODE. Writing the tracking expression inline on
   the provider — the shape `AutocompleteSearch` had at the PR base, plus a key
   — failed it. A guard that fails on correct code as well as passing broken
   code is worse than none, and only the false-positive control in the sweep
   caught it; every mutant in that batch behaved.

   Removing it re-opened a mutant it had been covering by accident: pinning a
   dropdown's JSX to a constant while leaving an unused tracking `const` above
   it. So the derive check now follows the expression the PROVIDER receives and
   resolves it one hop when it is a hoisted identifier. Both halves are needed
   and neither is sufficient — declaration-only lets the JSX be pinned,
   JSX-only rejects the inline form.

Sweep: 8 mutants — `handleTargetChange` emptied; `SearchLayout`'s index pinned
to an imported constant, to a map member and to a string literal; a dropdown's
JSX pinned while its const stays; either dropdown's const pinned — all 8 killed
on their own assertion, plus a FALSE-POSITIVE CONTROL that must stay green and
now does: the tracking expression written inline with no hoisted local.

Matrix: 26 at HEAD, 9 red with both components at bbeffcf71e — one fewer than
last round, because teaching the derive check to accept the inline form makes it
GREEN at the base, correctly: both roots already derived their index from the
target there. What the base lacked was the `key`. That test is relabelled an
invariant guard rather than counted as regression coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): a comment was enough to defeat the SearchLayout guard

Round-8 review, on the guard round 7 added.

  - It read UN-STRIPPED source — the only token-locating check in the file that
    did not go through `stripComments`, which is the exact failure that helper's
    own docblock describes. The mutant it exists for survived behind an ordinary
    code comment: remove the index prop, pin the provider to an imported
    constant, leave a line in the parameter list saying why. The identical pin
    WITHOUT the comment was red, so the comment, not the pin, was deciding the
    verdict. That pair is the measurement; one of them alone proves nothing.
  - It also rejected correct code in two shapes — normalising the prop through
    one local (`const resolvedIndex = indexName ?? …`), and the `export const`
    component style both sibling roots in this ledger already use — because it
    required the provider's literal expression to appear inside a literal
    `export function SearchLayout({…})`.

    Restated as what is actually required: the expression must REFERENCE the
    prop. Resolved one hop only when it does not already do so — this file also
    holds an unrelated `const indexName = Object.keys(uiState)?.[0]`, and
    resolving unconditionally binds to that and reddens a healthy root. That is
    the same trap a generic resolver hit two rounds ago; it is avoided here by
    not resolving what needs no resolving.

The one-hop resolution is now one shared helper for both the dropdown and the
`SearchLayout` check, and tolerates a type annotation on the declaration —
`const indexName: SearchIndex = searchIndexMap[targetIndex]` used to be rejected.

Also softened a coverage claim rather than widening a check: QuickSearchDropdown's
middle hop is a free-floating substring, weaker than AutocompleteSearch's pinned
declaration, and the comment now says so instead of implying parity. Tightening
it symmetrically would reject a correct consolidation into a shared handler.

Sweep: 2 mutants killed (the comment-shielded constant pin and its no-comment
control), and THREE FALSE-POSITIVE CONTROLS that must stay green and do — the
prop normalised one hop, a type-annotated declaration, and the tracking
expression written inline. This ladder has now produced one guard that passed
broken code and two that failed correct code; the controls are the half that
catches the second kind.

Matrix unchanged: 26 at HEAD, 9 red with both components at bbeffcf71e.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): the SearchLayout guard's live branch was a tautology

Round-9 review, on the fix round 8 landed. It closed the comment-shield hole and
opened a worse one in the same four lines.

The guard short-circuited the one-hop resolution whenever the expression already
contained the token `indexName` — which the live code's expression IS. So the
live branch compared the expression against a pattern it had just been tested
against: it could not fail, and the assertion that guards this root never
evaluated anything. Measured: dropping `indexName` from the destructuring,
leaving it in the prop type so no caller breaks, and shadowing it with a local
`const indexName: SearchIndex = 'models_v9'` left all 26 green — exactly the
defect the docblock above it names. The same mutant was RED against the
pre-round-8 file, so this was a regression that fix introduced, not a pre-existing
gap.

It also rejected the alias idiom both sibling roots in this ledger already use
(`indexName: indexNameProp`), because `\bindexName\b` does not match
`indexNameProp` and a destructuring alias is not a `const` declaration.

Restated as the property that is actually required: the index expression, after
one hop, must reference a name the component BINDS in its parameter
destructuring. Resolution is now scoped to the component rather than
short-circuited — slicing from the declaration is what makes it safe, since the
unrelated `const indexName = Object.keys(uiState)?.[0]` sits above the component
and is out of scope. That removes the tautology and the alias rejection at once.

Sweep: 2 mutants killed — the shadowed local constant, and a constant pin behind
a comment — with 2 FALSE-POSITIVE CONTROLS that stay green: the alias idiom, and
the prop normalised through one local.

Also swept the last unstripped read (the `static-index` row) into `stripComments`
for consistency, and recorded in the docblock that the anchored tracking pattern
rejects a `useMemo`-wrapped declaration — correct code, declined rather than
accommodated, with the reason.

Matrix unchanged: 26 at HEAD, 9 red with both components at bbeffcf71e.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): delete the SearchLayout index guard instead of tightening it again

Round-10 review, and the end of this ladder. The recommendation was to take the
guard down rather than fix it a fifth time, and it is right.

Four rounds were spent on one `if` block, each round's fix producing the next
round's finding. It passed broken code twice — an imported constant, then a
shadowing local behind a comment — and rejected correct code three times: a
normalisation hop, the `export const` component style both sibling roots already
use, and a reordered destructuring. Round 9's version also LOST a kill round 8
had while adding brittleness, and its failure message misdiagnosed every one of
those refactors, printing "it is pinned" about code that was not. It was a regex
approximation of scope resolution and name binding, and it never reached a fixed
point.

The requirement does not survive being questioned. `SearchLayout.tsx` is not
touched by this change — it is not among the five files in the diff — so the
guard protected an invariant no commit here can violate, at the cost of
reddening ordinary refactors. It contributed nothing to the red-at-base matrix,
then or now.

What remains for that root is `key === indexName`, which is order-independent,
style-independent, alias-tolerant, and is the claim the ledger exists to make.
A comment in its place records what was tried and asks for a defect before
anyone adds it back.

Verified the deletion removed no coverage that matters: the four dropdown
mutants still die — either root's tracking `const` pinned to a constant, a
root's JSX pinned while the `const` stays, and a `key` removed. Matrix
unchanged, 26 at HEAD and 9 red with both components at bbeffcf71e, because the
deleted assertion was never among those 9.

Also removed the now-dead comment strip inside `openingTag` — every caller
passes `stripComments`ed source — and corrected its docstring, which still
advertised it.

Net −45 lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(search): drop the client-side attribute guard, keep the keyed roots

The guard had no reachable true positive left. It is applied at exactly one
site, that factory has exactly three consumers, those reach exactly two
components, and both now carry key={indexName} on their <InstantSearch> root.
react-instantsearch-core only runs setIndex(indexName).search() in its render
body when prevProps.indexName !== props.indexName, and prevPropsRef is seeded
with the current props, so that branch cannot fire on the fresh mount a key
produces. There is no stale parameter set for the guard to catch.

What it still did there was save a round trip its own docblock called "no UX
change", relabel a beacon this PR expected to fall to ~0, and carry a
live-superset-of-code drift mode that can only fire falsely and would silently
empty a working search. Dropping it also leaves the comics_v1 beacons on the
existing MeiliSearchQueryError type, which is the only signal a separate
index-configuration issue has.

Removed with it, each because its only justification was serving the guard:

  searchFilterGuard.ts, searchFilterGuard.test.ts and
  __tests__/search-client-filter-guard.test.ts;

  the SearchFilterAttributeError beacon type, so the existing
  MeiliSearchQueryError population is left undisturbed;

  stripQuotedMeiliValues in meili-filter.ts -- sole consumer was the guard's
  attribute scanner, and it had no test outside the guard's;

  createErrorReportCap, pushSearchClientError and emptySearchResult in
  resilientSearchClient.ts -- each was extracted so the guard could share it,
  and each docblock says so. With one caller the extraction is unmotivated and
  the docs become false, so the file is restored to its pre-PR content byte for
  byte;

  the search-client-factory consolidation and the autocomplete/quick-search
  client modules. The factory's own docblock gives the guard as its reason: "a
  wrapper added to two of three call sites leaves the third silently
  unprotected". With no wrapper to apply, what is left is de-duplication --
  defensible, but a separate change, and it would land untested once the
  ledger that exercised it goes. AutocompleteSearch.tsx, QuickSearchDropdown.tsx
  and search.client.ts get their pre-PR client construction back verbatim;

  EXCLUDED_CLIENT_SITES, which lived in the deleted ledger. Its surviving
  invariant -- every <InstantSearch> root is keyed or targets a fixed index --
  is covered by INSTANT_SEARCH_ROOTS in dropdown-index-remount.test.ts, which
  is derived from the tree rather than hand-listed.

allowDeselect={false} on QuickSearchDropdown's target selector also leaves this
PR. It is a one-line change to a control whose only reachable call site writes
into a funds-distribution field, so it is worth its own review rather than a
line in a 13-file diff. Its assertion leaves dropdown-index-remount.test.ts
with it; the AutocompleteSearch one stays, because that prop predates this PR.

Removing it makes QuickSearchDropdown's fallbackIndex reachable -- a deselect
hands the change handler null -- so the comment that called it unreachable is
corrected rather than left standing.

The false claim that motivated the guard is gone with it. The sentence
"the dropdown surfaces cannot use that remedy because remounting clears the
user's typed query" is untrue at HEAD: this PR does exactly that, twice, and
useCarriedSearchText is what carries the text across. Swept the whole tree for
it with comment leaders and newlines normalised away, since it wrapped across
comment lines; two differently-shaped scans over all 10,925 tracked files, each
with a positive control, found it only in the two files deleted here.

* fix(search): make the carried text survive the blur, and keep the selector out of the remount

Round-1 fix round on #4953. Four approved changes, no behaviour outside them.

1. The carry was INERT on AutocompleteSearch. Its input bound onBlur to the same
   handler as the clear button, and that handler writes the carrier — so clicking
   the category selector (which blurs the input first) emptied the carrier a moment
   before the index switch it exists to survive. useCarriedSearchText now also
   returns a display-only clear; blur uses it, the clear button still goes through
   the setter, and onClear?.() fires from both so the mobile overlay still closes.
   The carrier is emptied when NAVIGATION moves the target, so only a pick from the
   selector re-seeds and abandoned text cannot reappear on the next link followed.

2. Both index selectors are now rendered ABOVE <InstantSearch>. The provider
   returns null until its start effect has run, so a key change commits one render
   with no subtree at all: the control the user just clicked was destroyed and
   rebuilt by their own click, dropping focus to <body>. Neither selector consumes
   the provider's context. Whether that null commit is PAINTED was not measured.

3. The selector-value clamp pin is now compared with whitespace removed from both
   sides. Both call sites had sat at exactly printWidth, so one rename or one indent
   level would have reddened it for a prettier re-wrap. Mutating the clamp still
   fails it, and a deliberate re-wrap does not.

4. QuickSearchDropdown's fallbackIndex comment said the target stays inside the set
   the selector OFFERS. It does not: the clamp reads supportedIndexes, and the
   offered list narrows that further by feature flag. Reworded to what the code does.

Coverage: the blur-then-switch path had none — every carry assertion was a source
spelling check and the behavioural harness modelled a remount with no blur. Added
that case plus a same-carrier negative control that differs only in which setter the
empty value goes through, a file-order pin for each selector's position, and a
spelling pin for the blur wiring including the onClear?.() in both handlers.

* fix(search): bound the carried search text, and correct four claims it outgrew

Round-2 fixes on the carried-search-text change.

BEHAVIOUR (6 added / 2 removed executable lines, all in AutocompleteSearch.tsx)

The carrier was emptied on exactly one path: a navigation that CHANGES
`searchTarget`. That is narrower than the comment claimed, in three ways —
any first path segment outside `targetData` collapses to 'models', so
navigation within a section never fires it; and both `handleSubmit` and
Escape reached the display-only blur clear, which leaves the carrier loaded.
So text typed on '/', blurred away (no clear button remains, since
`clearable` keys on the visible query) and left behind could resurrect on a
later category pick, and be searched.

Submitting and pressing Escape are unambiguous "done with this text" signals,
so both now discard the carrier, through one named function. NOT inside
`blurInput`: both of its callers are those two paths today, so the placement
is behaviourally identical at this head, but `blurInput` is a DOM verb and
the discard is a claim about intent — a future caller that is not a done
signal must not inherit it. There is no imperative blur either way; the
handle exposes `focus` only. And NOT on plain blur: reaching the category
selector requires blurring, which is what made the carry inert before.

The same-section-navigation residue remains, deliberately. Both the effect
and the hook doc now say so instead of implying a tighter bound.

CLAIMS CORRECTED

- The `enabledTargets` comment described the display-value clamp with the
  same word the adjacent `fallbackIndex` comment uses for a different set.
  Rewritten in both dropdowns to say which set each narrows.
- QuickSearchDropdown's Select: the stated reason for CONTROLLED was that the
  remount reset the Select's internal state. The hoist removed that mechanism
  and `targetIndex` there now has exactly one writer, so the justification is
  recorded as GONE rather than replaced. What still needs `value` is the
  `null` branch beside it. The AutocompleteSearch sibling is left alone — it
  genuinely has a second writer.
- "a key change commits one render in which the subtree is gone" understated
  it: `<InstantSearch>` renders null whenever its instance is not started, and
  start runs from a subscription callback after a commit, so every fresh
  provider does this. Scoped in both files and in the test. No paint claim.

SCAFFOLDING

- The `defaultValue` guards discriminated nothing. QuickSearchDropdown's
  needle `defaultValue={enabledTargets[0]}` is an object where Mantine wants
  `string | null` and fails TS2322, so it could never have been written; the
  realistic reverts typecheck and were MEASURED to leave the round-1 suite
  fully green (30/30) on both dropdowns. Now pinned on the PROP, scoped to
  the selector's own source.
- Removed the dead `onTargetChange` alternation arm from the one-writer count
  and the two comments citing that spelling; it exists nowhere in src/, and
  an earlier assertion in the same test already fails on the base spelling.
- "one hop, not the three it used to be" then drew two. Corrected, and the
  removed hop named.
- `containsIgnoringWhitespace`'s "no mutation is reachable by whitespace
  alone" is true of its needle, not of the helper. Scoped to the needle.
- Stated plainly that the three discards are spelling-pinned only, and that
  they are not sufficient.

VERIFICATION

Red-at-base / green-at-HEAD: 1 test, `AutocompleteSearch discards the carried
text on submit and on Escape` — 1 failed / 30 passed at d13739cc, 31 passed
at HEAD. Behavioural, not a rename artifact: it names a function the base
does not have. Every other changed assertion is green at base and is labelled
an invariant guard in place.

Mutation sweep, 6 mutants, all killed by their own guard's message, restore
digest-verified between each, post-sweep 31/31 green.

Gates: typecheck 0 errors (negative control: injecting the old guard's own
needle produced 1 TS2322, then 0 on restore); vitest unit over both component
dirs 224/224 across 9 files; eslint 0 errors / 11 warnings, identical count at
base (negative control: an injected violation moved it to 1); prettier clean
on all four files (negative control: an injected reformat produced one [warn]).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(search): correct three claims the previous commit's own comments got wrong

Self-review of the round-2 comments against the code at this head, which is
the check this ladder keeps skipping. Comments only; no behaviour change.

- "Both of `blurInput`'s callers are these two paths today" was written from
  the pre-change shape. After the change `blurInput` has exactly ONE caller,
  `blurAndDiscardCarriedText`. The argument for not putting the discard inside
  it is unchanged and now stated from what the code actually is.

- "The other two discards are `blurAndDiscardCarriedText` (submit, Escape) and
  the clear button" names three things as two. Split: one explicit discard
  (`blurAndDiscardCarriedText`, on two paths) plus the setter path an emptied
  input takes.

- "that name exists nowhere in `src/` now", of `onTargetChange`, is false:
  the same commit reintroduced it into the test file's own prose as a
  historical reference. Narrowed to what was actually measured — no SOURCE
  file spells it.

Gates re-run at this head: typecheck 0 errors; vitest unit over both
component dirs 224/224 across 9 files; prettier clean on all four; the
6-mutant sweep still kills all six, post-sweep 31/31.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): restore the cross-spelling leftover-copy kill, on the argument

F1. The previous round narrowed the exactly-one-writer count from
(?:setTargetIndex|onTargetChange)\(searchTarget to setTargetIndex alone, on the
rationale that the dropped arm "could not have discriminated anyway - a revert to
the base spelling fails the indexOf assertion above before reaching this line".
That sentence reasons about a FULL revert. The guard's own comment three lines up
says it exists for the PARTIAL one: hoisting the sync while leaving the old copy
in place. In that shape the hoisted setTargetIndex(searchTarget) is still there,
so indexOf succeeds and execution does reach the count - and the leftover copy is
spelled onTargetChange(searchTarget as TKey), which is exactly what the merge base
writes. The narrowed count is blind to it. The sentence is deleted, not replaced
with a fresh one.

Restored as a count on the ARGUMENT rather than on the alternation: a leftover copy
carries whatever name the boundary it crossed had, so a two-name alternation goes
blind the moment a third appears. Any call taking searchTarget is counted; today
the sync is the only one. Cost stated in the comment - a legitimate future reader
of searchTarget reddens this too, and the fix is to re-pin, not to loosen.

Measured, whole file, 31 tests, restores digest-verified between arms:
  leftover-copy mutant + pre-fix test      GREEN 31/31   (the coverage loss)
  leftover-copy mutant + this head         RED, this assertion's own message
  same-spelling duplicate + this head      RED           (nothing else narrowed)
  same-spelling duplicate + pre-fix test   RED           (pre-fix instrument works)
  clean tree at this head                  GREEN 31/31   (before and after the sweep)
Each RED was the only failing test in its run.

F2. The blurAndDiscardCarriedText comment called submit and Escape "the two ways a
user says they are finished", which is not the set: handleItemClick navigates and
fires the same onSubmit?.() while discarding nothing. Scoped to the two blur paths
and the pick path named, with what actually decides the carrier there. Comment
only - no executable change in AutocompleteSearch.tsx.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 12:58:19 -05:00
Zachary Lowden a75fb42290 fix(blocks): an unreadable post subject is no longer reported as "posting from apps is not enabled" (#4976)
`authorizeBlockPostRequest` passed an unhydratable session subject straight
into `isAppBlocksPostCreationEnabled({ user: subjectUser ?? undefined })` --
the no-entity arm. A segment-scoped rollout cannot match a no-entity eval, so
it answers false, and a failed identity read was rendered to the viewer as
"posting from apps is not enabled". Two different facts, one message, and only
one of them is about permission.

Refuse an unhydratable subject on its own terms before the flag is consulted,
matching the two sibling guards in this router family. The flag denial keeps
its message unchanged, so the two stay separable.

This does NOT widen who may post: an unreadable subject is still refused, and
a dedicated case pins that it is still refused under a base-enabled flag. It
also does not assert a mechanism for any particular production refusal -- a
subject carrying a stale isModerator and a transient flag-evaluation failure
produce the identical observable and neither is excluded.

- BlockPostRequestAuth.subjectUser is now non-nullable, so widening it back is
  a type error rather than a silent re-conflation.
- New counter civitai_app_block_post_subject_refusals_total{surface}, following
  the existing emitters in app-block-runtime.metrics.ts. A log line would not
  have worked: application-container logs are not collected for this
  deployment, which is why the original refusal was unattributable.
- Watchlisted as post-subject-refusal -- losing the branch to a bundler falls
  back to the no-entity arm, which returns the flag's BASE value.

Regression matrix (5 of 6 new cases): red at 85a3786116, green at HEAD.
The sixth, the flag-denial positive control, passes on both trees and is an
invariant guard, not regression coverage.
2026-09-19 11:33:40 -05:00
Justin Maier 2df0241a4f feat(test-cache): skip unit test files unchanged since they last passed (#4971)
* feat(test-cache): shadow-record what a content-keyed result cache would skip

Phase 1 of a test-result cache: nothing is skipped. A vitest reporter keys
each test file on the content of every first-party module it depends on, plus
the inputs no import records (lockfile, vitest config, tsconfig, node,
platform), and records files that passed in full. A later run whose key
matches is one the cache WOULD skip; if that file then fails, the key missed a
dependency and the run is logged as a false skip.

Dependencies come from vite's server-side ssr module graph, not
diagnostic().importDurations: on a fixture, importDurations missed an
`await import()` made inside a test body, and the ssr graph caught it cold and
warm. The graph also leaves out the subtree behind a vi.mock factory, which
never executes, while keeping the mocked module itself.

The store lives in the COMMON git dir, shared by every worktree, and keys use
repo-relative paths, so one tree's green run covers every tree whose files are
identical.

Files that read the filesystem, spawn processes, or import a non-literal
specifier always run (243 of 1,880, 6.5% of modelled worker time).

The queue gains a hot-configurable cache mode (`test config --cache shadow`,
TEST_CACHE_MODE in the skill .env). In shadow mode a queued unit run gets the
primary checkout's reporter appended, plus `--reporter=default` when the caller
named none, so turning it on never strips a run's normal output.

Fixture sequence, each step as predicted: cold 0 skipped; unchanged 1/1;
runtime-imported dep changed 0; unchanged again 1/1; dep behind a mock changed
still 1/1; env-driven failure with an unchanged key flagged as 1 false skip.
89-file yardstick, cold then warm: 89/89 passed both times, warm would skip
85/89 (98% of worker time), 0 false skips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(test-cache): skip unit test files unchanged since they last passed

Turns the shadow recorder into a real cache. Before a queued unit run, a
custom sequencer re-fingerprints each test file's recorded dependencies and
drops the files whose key still matches a recorded pass; vitest runs exactly
the list the sequencer returns. Reusing the OLD dependency list is sound:
gaining a dependency means editing a file already in the list, which changes
the key.

A key covers the test file, every first-party module it imports (from vite's
module graph, in whichever environment loaded it, so happy-dom files count
too), every file or directory it read at runtime (a setup-file fs tracker,
directories fingerprinted by their whole subtree), and the lockfile, configs,
node, platform, vitest and the cache's own code. Records are shared by every
worktree through the common git dir, up to 8 per test file, so two trees on
different code both stay fast.

Still always run: files that spawn, glob, or import a computed specifier (19
files, 0.7% of modelled worker time). Known blind spot: environment
variables are not in the key.

A random ~5% of skippable files run anyway. If one fails, the cache predicted
a pass it could not deliver; it writes TRIPPED.json and runs everything until
a human removes it. Modes off|shadow|on are hot-configurable on the queue
(`test config --cache on`); never on in CI, never applied to a named-files run.

Measured, 89-file yardstick: cold 294s, warm 24s (85 skipped, 3 re-sampled,
0 false skips). Fixture scenarios: runtime-imported dep edited, fixture file
edited, dependency of a happy-dom test edited each re-ran exactly the affected
file; an env-driven failure was flagged as a false skip and tripped the cache.

Fixes found by those checks: a fully cached run exited 1 ("no test files");
44 happy-dom files were never cached; a builtin heuristic dropped top-level
directories like `src` from the key.

Full unit suite, cache off: Test Files 1 failed | 1904 passed | 3 skipped
(1908); the one failure is rest-error-envelope-ledger, which fails identically
on origin/main CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test-cache): close three false-skip paths found by review

Adversarial review of af72b46854 confirmed three ways a failing test could
be skipped green, each reproduced on a fixture:

- An input edited while the run was in flight was fingerprinted at the end
  and recorded as the version that passed. Records are now refused for any
  input modified after the run started (directories by their subtree).
- A run that failed on an unhandled error still recorded its files, because
  the module state stays "passed". Nothing is recorded from a run with
  unhandled errors, or an interrupted one.
- A computed import in a HELPER (`import(/* @vite-ignore */ file)`, the form
  pending-review-mute.test.ts uses) was invisible to the key. Every
  first-party module in the closure is now scanned, the comment form is
  matched, and importing child_process/worker_threads/cluster in the closure
  keeps a file uncached however it is called.

Also: a false skip now deletes that file's records, so clearing TRIPPED
cannot revive it; TRIPPED is written first and atomically, and an unreadable
marker reads as tripped; package.json files and pnpm's installed lock are in
the salt; a sibling that would shadow an import (foo.ts beside foo/index.ts)
changes the key; the sequencer skips nothing on a file-filtered run or when
the cache reporter is not loaded; messages go to stderr; a malformed sample
rate falls back to 5%; the fs tracker loads first and covers access/open/
realpath/readlink.

Two regressions inside this round, caught by a positive control that ordinary
tests still record: scanning the fs tracker's own closure (which imports
child_process) marked every test uncacheable, and a call-name pattern matched
`regex.exec(` in src/__tests__/setup.ts. The tracker is excluded as
instrumentation, and process use is detected by import, not call name.

Fixture battery, positive control first (recorded 2, then skipped 2): edited
mid-run not recorded and next run fails as it should; leaked rejection
blocks recording; helper computed import and namespaced child_process not
recorded; shadowing file re-runs; false skip trips and forgets. 89-file
yardstick: cold 63s, warm 14s, 84 skipped, 0 false skips; the two files kept
uncached are pending-review-mute (the reviewer's case) and a computed-import
hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test-cache): close the false skips the second review found

Re-review of fbe832cbdc confirmed three more false skips and one breakage:

- A dependency deleted or renamed mid-run was recorded as `missing`, which
  the next run matched. changedSince now asks the parent directory, whose
  mtime moves on a removal, and a module that was in the graph but is gone
  at the end refuses the record outright.
- `createRequire(...)('child_process')`, `process.getBuiltinModule(...)` and
  `from"node:child_process"` walked past the import-syntax patterns. The
  module name is now matched as a string anywhere, plus the common spawn
  wrappers. The graph-level builtin check is deleted: builtins never enter
  vite's graph, so it could not fire.
- The tracker's wrappers dropped properties living on the function, so
  `fs.realpathSync.native` (called by next off-Windows) vanished with the
  cache on. Own properties are copied and `.native` is wrapped.
- An unhandled error now blocks only the file vitest attributes it to
  (VITEST_TEST_PATH, verified present on a leaked rejection); an
  unattributed one still blocks the run. The key is taken before the change
  check, closing the window between them. A TRIPPED rename that EPERMs
  writes in place instead of aborting before records are forgotten.

The reporter and sequencer now have their own tests, driven with fake
vitest objects (scripts/__tests__/test-cache-reporter.test.ts), led by a
positive control that an ordinary file IS recorded. 13 revert controls,
each red on its own named test. The changedSince test now actually moves
one input past the run start per case, and covers deletion.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(test-cache): stop the round-2 fixes from making the cache inert

Third review of 3101975fe1 found no new false skips, but two regressions
from the previous round that left the cache recording NOTHING on the real
repo — every test ran, safely, and none was ever skipped:

- The process check matched a bare quoted 'cluster', an ordinary value in
  the Redis and telemetry code every test's setup reaches: 1880/1880 unit
  tests uncacheable (36/1880 without it). It now matches the module name
  only in import-shaped positions: from, import(, require(,
  getBuiltinModule(, and a call on a call (createRequire(...)('...')).
- The deletion check read a missing PARENT as "changed". Every test probes
  __snapshots__/<file>.snap in a directory that usually never existed, so
  every record was refused. It now asks the nearest existing ancestor,
  which still moves when a file or a whole subtree is removed.

The per-run memo on the change check is restored (~19s of synchronous work
at the end of a full run without it).

The fake-driven positive control stayed green through both, because the
fakes modelled neither the snapshot probe nor a setup closure mentioning
'cluster'. Added:
- a fake control shaped like a real file (snapshot probe + that closure);
- scripts/__tests__/test-cache-e2e.test.ts, which runs REAL vitest with the
  real sequencer, reporter and tracker over a one-file fixture twice and
  asserts recorded 1, then ran 0 / skipped 1 (3.8s).
Reverting either fix reddens both. The e2e test's first control did NOT
redden, which exposed that the tracker exclusion covered the whole
scripts/test-cache/ directory — including the fixture's own setup file. It
now excludes exactly scripts/test-cache/fs-tracker.mjs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 01:13:43 -06:00
Justin Maier fdc1437da1 fix(users): scrub name, profile and links on account deletion; stop persisting the OAuth name (#4970)
* fix(users): scrub name, profile and links on account deletion; stop persisting the OAuth name

Account deletion is a soft delete, so no FK cascade fires. On prod, of 1,330,849
deleted accounts, 733,857 still carried `name` and 192,791 a UserProfile row.

- apps/auth: stop writing User.name on OAuth signup. It is unverified,
  user-controlled data that outlives a soft delete. It still seeds the
  generated username from the transient profile.
- deleteUser: also null `name` and delete the UserProfile row and every
  UserLink row, inside the transaction.
- Move the paddleCustomerId purge out of the transaction into a `finally`
  after the subscription cancels. cancelSubscriptionPlan falls back to
  reading it, so while it was nulled in the transaction that fallback could
  never fire on a deletion. The `finally` keeps the purge unskippable when
  an earlier unwrapped await throws.

customerId is deliberately NOT purged here: deleteUser's own
cancelSubscription triggers a Stripe webhook that resolves the user by
customerId and throws before deleting the CustomerSubscription row, so
nulling it would leave the row `active` forever. It is purged by the GDPR
scrub, which must reach Stripe first. Pinned by a test named for it.

Staff accounts created after this change file NCMEC reports without a
reporter firstName; the live report path reads `name` for nothing else.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(users): keep paddleCustomerId in the deletion transaction; harden the scrub tests

Reverts the paddleCustomerId ordering change from the previous commit. Moving
the null after the subscription cancels let cancelSubscriptionPlan's no-row
fallback run, but with seven live Paddle subscriptions (none on a deleted
account) it only added a live Paddle API call per deletion, a false
cancel-paddle-subscription error for nearly every one, and an unbounded wait
on a client with no timeout. paddleCustomerId is nulled inside the
transaction again, exactly as on main, and the try/finally that existed only
to protect that later null is removed, restoring main's tail.

deleteUser's net change is now only the GDPR scrub: null `name` and delete the
UserProfile and UserLink rows inside the transaction.

Tests, from the five-lane review:
- the customerId scan serialises BigInt instead of falling back to
  String(call), which turned an object into "[object Object]" and reported a
  false absence; a CONTROL pins it
- the scan's uncovered write paths are listed (kyselyWrite,
  updateManyAndReturn), alongside pgDbWrite and interactive transactions
- tests that only made sense for the reverted ordering are removed, and one
  pins paddleCustomerId inside the transaction

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(users): pin the soft delete inside the transaction; cover interactive transactions

From the test-lane re-review of #4970:

- Nothing asserted that the soft-delete user.update is itself one of the
  $transaction ops. Awaiting it outside the array passed every test, including
  the ones named "inside the transaction". Both the transaction test and the
  paddleCustomerId test now assert its identity in the ops array.
- A test-local $transaction override returned its argument unrun, which hid
  customerId writes made inside an interactive transaction. The shared mock
  runs the callback, so the override is removed and a CONTROL proves the scan
  now sees that route.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-19 00:16:12 -06:00
Zachary Lowden 85a3786116 test(rest-envelope-ledger): record admin/huggingface-import.ts, fixing main's red (#4973)
The REST error-envelope ledger fails on main and therefore on every open PR,
because GitHub Actions builds the merge commit: the offender set gained
`admin/huggingface-import.ts`, added by d6d5f856a6 ("feat(models): a server-side
Hugging Face transfer API that attaches on completion"). Nothing on the PR
branches is involved.

The ledger is working as designed — it is a structural guard that fails when its
set GROWS or SHRINKS, and this is a genuine growth. The disposition it asks for
is the one its own docblock records for this tier:

- The route is `WebhookEndpoint`, i.e. WEBHOOK_TOKEN only — TIER 3, the tier the
  ledger describes as "operator surfaces … fixing them is optional and may be
  undesirable".
- The flagged value is `HuggingFaceError.message`, a class declared in
  `huggingface.service.ts` whose text this repo writes. It is never a Prisma or
  pg error, so no table, column or row value can reach it. Stated honestly in
  the entry: one branch forwards up to 200 chars of Hugging Face's own response
  body, which is third-party text rather than our internals.
- Delegating it to `handleEndpointError` would genericize a 400 that IS the
  answer the caller acts on ("the repo is gated or private; importing it needs a
  token whose account has accepted its terms"), leaving an operator a bare 400
  and nothing to fix. That is the same trade the ledger's TIER-1 block already
  records against a blind delegation.
- The passthrough stays pinned behaviourally by
  `src/server/__tests__/huggingface-import-endpoint.test.ts` ("passes Hugging
  Face's own refusal back as a 400"), so it cannot drift just because the ledger
  stops looking at it.

Red-then-green, at this worktree:
- origin/main, ledger suite: 1 failed | 10 passed (11) — offender set had 53
  entries against a 52-entry baseline, the extra being admin/huggingface-import.ts.
- this commit, ledger suite: 11 passed (11).
- this commit, ledger + the five suites that read the same endpoint or the same
  envelope helper: 144 passed (144) across 6 files;
  huggingface-import-endpoint.test.ts alone 16 passed (16).
2026-09-18 22:58:54 -05:00
Justin Maier 8540f2fe8d perf(shop): count sold per page instead of aggregating the whole purchases table (#4974)
* perf(shop): count sold per page instead of aggregating the whole purchases table

Prisma resolves a relation _count by aggregating all of
UserCosmeticShopPurchases once per query, so every shop read paid for the
whole table regardless of page size, and MostPopular paid twice (value and
orderBy). The display reads now take the count from one query restricted to
the ids they returned.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(shop): pin the sold-count statement and the call-site selects

The fake answered by table name alone, so a renamed alias, a dropped int
cast or the wrong WHERE column all stayed green. Rows are now projected
onto the statement's SELECT list and the statement is pinned once. Each
read that dropped the whole-table _count now asserts it at its call site,
and getCreatorShopManageItems gets its first tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(shop): pin per-item sold mapping on multi-item pages and the resale filter

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:51:04 -06:00
Justin Maier 8113e62777 fix(metrics): exclude metric-suppressed accounts from Postgres reaction sums (#4959)
* fix(metrics): exclude metric-suppressed accounts from Postgres reaction sums

The reaction counts shown on posts, articles and bounty entries are summed in
Postgres from ImageReaction/ArticleReaction/BountyEntryReaction with no exclusion
predicate, so they count accounts the reaction-abuse detector already suppressed
from every other reaction surface. Unlike the ClickHouse totals these never decay:
the jobs recompute the same unfiltered sum from the same rows, so the numbers stay
wrong until the queries filter.

Post has two live reaction queries, not one — post.metrics.ts delegates to
post.metrics-old.ts whenever the simplified-post-metrics flag reads false, which
includes Flipt being unreachable. Both filter now.

The jobs read the list through a new getMetricExcludedUserIdsOrThrow rather than
the existing lenient reader. The lenient one degrades to [] so the reaction
milestone keeps firing during an outage; a metric job doing that would write an
unfiltered total that nothing later recomputes, because a job only revisits an
entity that receives another reaction. Rejecting instead leaves the cursor and the
queue untouched in createMetricProcessor, so the window is recomputed next run.

Answer and Question reaction metrics have the same shape and are left alone — out
of the scope this was asked for, and named as exemptions in the guard rather than
skipped silently.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(metrics): zero the entities whose reactions are ALL excluded, and filter the milestones too

Five review lanes over 9cb53ed. Everything here is a finding they raised, each verified
against the code or the replica before acting on it.

Filtering the aggregate was not enough. An entity whose remaining countable reactions
are zero produces NO ROW from a GROUP BY over the reaction table, and a missing row means
"no change" to every writer downstream — so the pre-exclusion total survived even a full
recompute. Measured on the replica: 18 of 594 affected articles and 338 of 1,154 affected
bounty entries are in that state. Post and article seed zeros into ctx.updates before the
aggregate overwrites them; bountyEntry has no JS intermediate, so its CTE now drives from
the affected ids with a LEFT JOIN.

That LEFT JOIN needed timeframeSum to be NULL-safe. Its leading `WHEN NOT (cond) THEN 0`
is NULL for an unmatched row and falls through to the AllTime arm, counting a reaction
that is not there. `(cond) IS NOT TRUE` is identical for every inner-joined caller.
Verified on the replica: with the old form a fully-excluded entry returns 1 heart / 1 like,
with the new form 0.

The seeding was only safe once a pre-existing bug was fixed. Both post jobs bound their
chunk with `BETWEEN ids[0] AND ids[ids.length - 1]` over an unordered Set, so roughly half
of all chunks matched nothing. Seeding zeros into a chunk that matches nothing would have
written zeros over real counts. The chunk is sorted now.

The article and bounty-entry milestone notifications counted unfiltered. Before this work
both halves were unfiltered and therefore agreed; filtering only the displayed half would
have manufactured, for those two entities, the exact display-vs-notification divergence
this defect is a sibling of. They use the LENIENT reader on purpose — a notification
should degrade to the old count, not to silence.

The guard now covers the notifications too, and three mutations that were demonstrated to
pass against it: a `.catch(() => [])` on the strict read, a filter spliced inside an SQL
line comment, and a wrong column argument. The last is fixed by construction — the column
is hardcoded rather than passed, since a raw-SQL parameter beside an integer guard reads
as though the guard covered it.

Also: the strict reader now reports the outage to Axiom instead of surfacing only as a
generic job error, and a comment claiming coercion parity with metric-reaction-repair.service.ts
was false and now says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(metrics): assert the emitted SQL, not that a token appears in the file

Round 2 of review demonstrated six mutations that pass the source guard, each measured
rather than argued: a second unfiltered count of the same table in the same template
literal; a consistent alias swap so `r."userId"` names the image owner instead of the
reactor; `.then().catch()` and a plain try/catch around the strict read; a key moved
into the exemption list, which had no length pin; a `/* */` comment around the splice;
and the bounty-entry filter moved out of the LEFT JOIN's ON into a WHERE, which
collapses it to an inner join and restores the no-row defect the rewrite exists to fix.

A source guard checks that a token appears in a file. It cannot see what the composed
statement does, which is why six separate textual assertions each missed one of these in
their own way. `post-reaction-metrics-sql.test.ts` calls the real getReactionTasks with a
fake pg that captures every statement, and asserts on the SQL that was actually sent —
one test that catches the alias swap, the swallow, the commented splice, the second
count, the missing sort and the missing zero-fill.

Its fixture crosses the 30,000-image chunk boundary and returns a LOWER run of post ids
second. That is not decoration: the first version of the sort control PASSED, because
`getAffected` sorts its own return, so a single-chunk fixture cannot produce the
out-of-order set the bug needs. It was an assertion that could not fail for the case it
was named after.

The guard keeps the cases it can see, hardened: block comments as well as line comments,
a requirement that the filter be built from a direct `await` of the reader rather than
any expression with somewhere to swallow a rejection, a requirement that the alias `r` is
bound to the reaction table and to nothing else, a shape pin on the bounty-entry ON
clause, and a length pin on the exemption list.

Two fixes to the round-1 fix. The Axiom report was effectively unreachable: one latch
shared by both readers, and the lenient one runs on every reaction toggle, so it wins
every race and the only line for an incident would say a notification degraded while the
metric jobs stalled silently. Keyed per outcome now. And `post.metrics.ts` chunked image
ids from a ClickHouse query with no ORDER BY under the same inverted-BETWEEN bug fixed
one block below; post.metrics-old.ts has that ORDER BY, the live path did not.

Both `!clickhouse` branches had no test at all, in any file, because every other test
supplies a client.

Backfill note, recorded here because a squash merge takes commit messages and not the PR
body: this does NOT close ClickUp 868m6vftv. The filter only corrects an entity the next
time it is affected, so the already-wrong rows stay wrong until a backfill recomputes
them. That ships separately against main, not stacked on this branch.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(notifications): keep the ClickHouse client out of the client bundle

The milestone filter pulled the exclusion-list reader — and through it the ClickHouse
client — into the notification processor files. Those files are in the _app client
graph, because prepareMessage renders there. no-server-infra-in-app-graph caught it: it
ran and failed in 44ms before the full suite was killed by a daemon restart, so the one
real result that run produced was this.

A lazy import inside prepareQuery does not fix it. The guard says why and is right: a
dynamic import() still compiles the chunk into the client bundle.

So the processors no longer read the list at all. The server-only runner,
send-notifications.ts, reads it once per job run with the lenient reader and passes it
through NotificationProcessorRunInput, which prepareQuery already receives. The pure SQL
builder moves to ~/shared/utils/excluded-reactor-filter.ts with no imports, and
metrics/metric-helpers re-exports it. One read per run instead of one per processor.

The field is optional and the two reaction milestones default a missing list to [],
because degrading to the pre-exclusion count is already the posture these notifications
want, and ten existing processor tests construct the input without it. That makes the
runner the single point deciding whether milestones are filtered at all, so the guard now
pins it: it must read with the lenient reader and pass the list to every prepareQuery,
and a processor may import neither reader.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(notifications): pin the runner-to-milestone hand-off by behaviour, not spelling

Round 3 of review measured eight mutations that ship the reaction milestones unfiltered
with the source guard green, because the guard pinned how the hand-off is SPELLED rather
than what is passed: a shadowing `const excludedUserIds = []` inside the batch loop,
`excludedUserIds.length = 0` after the read, a spread that overrides the shorthand, a
processor that ignores its input, a filter built but never spliced, and the filter moved
into the `affected` CTE — valid SQL that narrows which entities are revisited while the
COUNT stays unfiltered.

send-notifications.excluded.test.ts runs the real job with the real processors, captures
the SQL they send, and asserts the filter lands in the CTE that COUNTS. All six of the
lane's mutations fail it by name; the processor-level ones fail only their own milestone.
It also asserts no processor logged an error, because the runner swallows a per-processor
throw and a milestone whose SQL no longer builds would otherwise read as "no query".

NotificationProcessorRunInput.excludedUserIds is now required but nullable rather than
optional. The milestones default a missing list to [], so a second runner that simply
omitted the key would ship every milestone unfiltered without a sound. Required, tsc
rejects it. Verified that the protection is real rather than vacuous: typecheck does not
read __tests__, which is why ten fixtures building this input still pass, so the control
was on the production caller — dropping the key from send-notifications.ts fails with
TS2345 at line 49.

Plus direct tests of the shared SQL builder's empty-list and non-integer branches, which
only the non-empty path had reached. With the integer guard removed, the three non-integer
cases fail.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(notifications): bound the counting-CTE slice instead of failing open

countingCte sliced from `affected_value AS (` to the next `), ` and, finding none, fell
back to the end of the query. Round 4 of review measured that as a green mutant: put the
next CTE's name on its own line and move the filter into `reaction_milestone`, a CTE
that counts nothing, and the slice ran on far enough to include it. The helper now bounds
the slice by the next CTE header and requires it to find one. That mutant is red now.

The same round confirmed the other six mutants go red on the assertion named for their
defect rather than incidentally, and found an alias-revert mutant (bounty table back to
`br` beside a filter that names `r`) that this test does not catch because it never
executes the SQL. It does not need to: no-unfiltered-reaction-metric-sum's alias
assertion fails on it, verified.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(notifications): give the pgDb mock its full export set

pgDbMock.parity requires every inline `~/server/db/pgDb` factory to list the complete
export set, because kyselyDb.ts destructures all of them at module-eval time and Vitest
throws on any omitted name — during module LOAD, so a suite that reaches it dies at
collection and reports zero tests rather than failing. The job test listed only
pgDbRead. It passed because kyselyDb is not in its graph, which is the case the guard
exists to stop depending on. Caught by the full suite, the only thing that runs it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:23:31 -06:00
Zachary Lowden 165da40294 feat(app-blocks): author-fee viewer-charge path (slice 2b, dark) (#4958)
* feat(app-blocks): slice 2b base — the author-fee settlement rail, rebased onto the merged ledger

Slice 2a (the accrual ledger) merged as dce428a492 via squash, so the previous
slice2b branch re-proposed the entire ledger against main. Rebuilt off main so
this branch carries ONLY the settlement delta: 4 files, +1016.

The six audit rounds behind this code are preserved on
zach/app-blocks-author-fee-audit-archive (7c2a38565f); GitHub auto-deleted the
2a branch on merge, which briefly made slice2b their only ref.

Still dark: nothing calls accrueBlockAuthorFee on main, the Flipt flag
app-blocks-author-fee-enabled is false, and the cron is registered in the job
array but not with the external scheduler.

* feat(app-blocks): author-fee viewer-charge path (slice 2b, dark)

Slice 1 computed the per-generation author fee and threw it away. Slice 2a
persisted an accrual ledger and a settlement rail with ZERO callers. This slice
is the caller: it prices the fee, debits the viewer, writes the accrual and
reverses both when the generation does not survive.

THE FEE IS PRICED INTO THE RESERVATION, NOT DEBITED OUTSIDE IT. This is the one
safety hole the design review found. The submit order on every path is
whatIf -> budget gate + reservations -> submitWorkflow -> charge. A fee debited
at the end would escape all five guardrails those reservations implement: the
token per-call budget, the per-user daily cap, the viewer's own per-app consent
budget, the per-app aggregate cap and the dev-tunnel backstop. The consent
budget in particular would then bound the part of the price the app does NOT
set while leaving the part it DOES set unbounded.

So the path splits in two. quoteBlockAuthorFee runs against the whatIf response
and its feeBuzz is folded into the number every gate and reservation reads.
chargeBlockAuthorFee runs against the realized submit response and takes the
reserved amount as a HARD CEILING - charged = min(reserved, realized). A path
that reserved nothing passes 0 and can then charge nothing, whatever the
realized base says, so the property is structural rather than conventional.

Two of the four submit paths are in that state deliberately: customComfy takes
no whatIf quote at all (its ceiling IS the declared maxBuzz) and the
pass-through quote helper returns a total only. Neither has a pre-submit
cost.base to price a fee from. Both still call the charge with 0 so the
population of submit paths stays closed and the seam guard can enumerate it.

SELF-DEALING IS READ BEFORE THE DEBIT. Slice 2a implemented the exclusion inside
the accrual writer and left a note saying a charge path must call it before the
debit or extract it. It is extracted - resolveBlockAuthorFeePayee - and the
quote calls it, so a self-dealing generation never even has a fee reserved. The
accrual still calls it as the write-side belt for any other caller. The owner
read stays in the file the ownership-gate ledger already enumerates.

THE FEE FOLLOWS THE GENERATION'S REFUND. pollWorkflow and cancelAppWorkflow
reverse on any non-succeeded terminal status: the debit is refunded and the
unsettled accrual row deleted. The DELETE is status-guarded, which is both the
settled-row refusal and the lock - only the caller whose delete matched issues
the refund, so concurrent terminal polls cannot double-refund. A SETTLED row is
refused rather than clawed back. The succeeded guard matters most on cancel,
which races completion.

Reconciled BY COUNT throughout: createBuzzTransactionMany does not throw on a
per-transaction failure, it drops the result from both arrays. A landed debit
whose accrual then failed is refunded under the reversal key, so a later
terminal reversal conflicts instead of paying twice.

NO MIGRATION. There is no clawed_back status: the status CHECK is constrained to
('accrued','settled'), every migration here is applied by hand per environment,
and a status-guarded DELETE is the atomic claim the reversal needs anyway. The
consequence is stated in the code - a reversed generation leaves no row, so the
reversal count lives only in the block-author-fee Axiom stream.

STILL DARK. Every entry point is behind app-blocks-author-fee-enabled, which is
false. With the flag off no fee is quoted, so nothing is reserved, so the clamp
makes a charge impossible and the ledger stays empty.

Also folds the txt2img spend-basis derivation into the shared
deriveBlockSpendBasis the other three paths already used - the fee and the
attribution must agree on the currency, and the inline copy was unreachable
from where the fee needed the answer. The attribution fallback keeps reading
the orchestrator's own quoted total, not the fee-inclusive cost.

TESTS
- src/server/services/__tests__/no-divergent-author-fee-base.test.ts gains 9
  guards over the router call sites. Measured at dce428a492: 7 RED, 2 VACUOUSLY
  GREEN. The two green ones iterate the (empty) call-site array, so they assert
  nothing there; what stops that being a hole is the positive control and the
  count assertion in the same describe, both of which are red. This is the
  regression coverage for the change.
- author-fee-charge.service.test.ts is 29 NEW-MODULE tests, not regression
  coverage - at the base they cannot run at all because the module does not
  exist. What makes them real is the mutation sweep: 21 counted mutants, all 21
  killed, each by its own named assertion. A 22nd was RETRACTED rather than
  reported as a survivor - it inserted a self-dealing branch AFTER
  resolveBlockAuthorFeePayee had already returned, so it was unreachable and
  proved nothing either way. Its replacements break the real predicate
  (M4a/M4b) and kill the charge-path test, which is what proves the charge
  actually reaches the shared predicate rather than merely importing it.
- Prisma is mocked at the module boundary in every one of them, so none of this
  is a claim about behaviour against a real database.

* fix(app-blocks): make the author-fee charge total and the reversal guard structural

Two rounds of adversarial audit on the slice-2b viewer-charge path. Both
blockers moved money in a direction D1 forbids; the rest are guards that read
as coverage while providing none, and comments the change falsified.

BLOCKER 1 — chargeBlockAuthorFee was not total.
The accrueBlockAuthorFee call sat outside every try. It reaches
resolveBlockAuthorFeePayee, whose dbRead.oauthClient.findUnique is documented
to PROPAGATE, and by that point the viewer's debit has already landed. The
rejection escaped to the router: viewer charged, no accrual row, no refund,
and the generation surfaced as an error the viewer had nonetheless paid for.
Wrapped, routed into the same refund branch reason 'error' already takes, and
the two comments asserting the false property now describe what the code does.

BLOCKER 2 — the "already settled" guard was spelled on status.
Settlement MINTS at createBuzzTransactionMany and only flips status at the
updateMany after it, so between those statements the money is the author's
while the row still reads accrued. The reachable arm is not that race: when
the flip throws, flipFailures is incremented and rows stay accrued until the
next nightly run, so every reversal in that ~24h window deterministically
double-paid. The guard is now the settlement rail's OWN eligibility boundary,
exported as one predicate and read by both sides: a row accrued in the current
UTC day is invisible to every settlement run, so no mint can have been
attempted for it. Re-asserted in the DELETE so it is a CLAIM, not a check.
NO SCHEMA CHANGE — settlement_key is forbidden on an unsettled row by
block_author_fee_accrual_settled_key_check, so the obvious pre-mint claim
column is unavailable without a hand-applied migration.

Also in this batch:

- cancelAppWorkflow issues a real orchestrator cancel and reversed NOTHING.
  Three artefacts named it as one of the two reversing observers; it was not
  one of them. A block cancelling a queued generation through it got the
  generation refunded while the accrual stood and the nightly job minted the
  fee. Wired, and the seam guard now derives its population from the CANCEL
  sites instead of counting the reversal sites that happen to exist.
- Both cancel paths guarded only on status !== 'succeeded'. cancelWorkflow
  resolves on a non-2xx PATCH, so a failed cancel re-read as 'processing' and
  reversed anyway: viewer keeps the generation AND the fee. All three
  observers now spell the same terminal + not-succeeded guard.
- The 'whatif' sentinel was not excluded at the charge guards. Under it the
  charge key and the UNIQUE accrual row are shared across every viewer, so a
  second such generation conflicts, reports charged:true having debited
  nothing, and a later reversal refunds the wrong viewer.
- persistCustomComfySettle was handed the fee-inclusive ceiling while
  settleCustomComfySpend refunds against the generation-only cost.
- Deleted the two dead chargeBlockAuthorFee calls that returned
  'not-reserved' at the callee's first statement, and the two
  deriveBlockSpendBasis hoists that moved onto the awaited request path only
  to feed them. The population stays closed via a NO_FEE_PATHS ledger that
  fails on growth AND shrink, following no-unguarded-billable-submit.
- Three mutants that survived the full suite are now pinned: the charge's
  buzzType, capOverage's comparand, and the charge being awaited.
- The settlement flip's own status guard had no killing mutation; its where
  clause is now asserted.
- Corrected the claims the change falsified: "every entry point is behind the
  flag" (reverseBlockAuthorFee reads none, deliberately), the capOverage
  comparand, and the unknown-`variable` tri-state, which slice 2b decides.

Tests: 83 across the four author-fee suites, up from 75. Nine are RED at
1672a1e3cc, each with its own assertion. Everything mocks Prisma and the Buzz
service at the module boundary — no claim is made about real-database or
live-Buzz behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(app-blocks): pin every fee reversal to its own procedure input id

The reversal deletes a row and issues a refund, and all three observing
procedures have a snapshot or projection in scope carrying a different
workflow id, so a site keyed on the wrong one refunds the wrong viewer.
Mutation-checked: keying cancelAppWorkflow on canceledWorkflow.workflowId
fails "EVERY procedure that cancels a workflow also reverses the fee" with
`expected ... to contain 'workflowId: input.workflowId'`.

terminalStatus is deliberately not pinned to one expression — the poll and
cancelWorkflow read snapshot.status, cancelAppWorkflow reads its projection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(app-blocks): make the author-fee comments say what the code does

Round 2 of the slice-2b delta audit. Every finding but one is a FALSE OR
MISLEADING CLAIM rather than a logic bug: the code is largely right and the
comments, docblocks and PR body describing it are not. So this round changes
words, not behaviour — with one exception, which is a test.

BLOCKING — the documented reversibility window was 2.5h wider than the code's.
Three artefacts said a fee accrued on day D is reversible "until the settlement
job runs at 02:30 UTC on D+1". The guard is isSettlementEligible(accruedAt, now)
= accruedAt < utcDayStart(now), so reversibility ends at 00:00 UTC on D+1 and
the job's schedule is irrelevant to it. Both documents already stated it
correctly elsewhere and contradicted themselves. Corrected everywhere, with the
worked case a reader has to get right: accrued 2026-09-18T14:00Z, polled failed
at 2026-09-19T01:00Z is REFUSED — nothing has minted and the fee is not
reversible.

The accruedAt re-assertion in the reversal deleteMany is INERT, and two comments
claimed it closes a race it cannot. `now` is frozen before the read and
accrued_at is written once by the column default and never updated, so the
boundary comparison has the same answer at the check and at the claim. `status`
can move between the two statements; accruedAt cannot. The clause is KEPT
(harmless defence-in-depth, and removing it would churn the WHERE-shape pin) and
both comments now say what is true: the row is safe because the settlement scan
cannot see it at all, `status` is the clause that closes a real race, accruedAt
is belt-and-braces made redundant by the frozen clock.

The unknown-`variable` tri-state was motivated by the one case where its own
claim fails. "A viewer charged for a cap-priced generation is one reversal" is
false: a cap price is what the orchestrator prorates, proration happens on
`succeeded`, and all three observers gate on status !== 'succeeded' — so there
is no reversal at all for that case, as reverseBlockAuthorFee's own docblock
says. The behaviour is UNCHANGED (`=== true`, charge on unknown); the
justification now rests on visibility, and the residue paragraph says the fee is
NOT reversible when the generation succeeds. Cap-priced generations were
measured at 0.94% of App Blocks traffic (5 of 532, all one app).

"byte-restored to dce428a492" was FALSE, and the difference is an undeclared
behaviour change. submitCustomComfyWorkflow and submitPassThroughStepWorkflow
are code-identical to the base except one hunk each: the guard gained
spendWorkflowId !== 'whatif'. Those paths charge no author fee, so the whatif
rationale (shared Buzz idempotency key, UNIQUE accrual row) does not apply; the
only consumer inside those blocks is recordSpendAttribution, so the delta stops
writing spend-attribution rows for submits whose orchestrator response carries
no workflow id. The exclusion is KEPT — a shared 'whatif' attribution key has
the same cross-viewer collision shape — and is now DECLARED at both sites,
ledgered in NO_FEE_PATHS with its own reason, and pinned by a test rather than
inherited from a loop over all four submit markers.

Also:

- reason 'already-settled' is unreachable in production: the eligibility guard
  precedes the status guard and the only writer of status='settled' can only
  touch rows eligibility already refuses, so it needs app-clock skew across
  midnight (or a manual settlement run with a future date). An operator alerting
  on that log line would get permanent silence. The docblock presented it as an
  ordinary operational distinction and a test comment named the wrong mechanism
  ("only if a settlement run is mid-flight" — mid-flight is not sufficient).
- "9 RED at 1672a1e3cc, each on its own assertion" was false for 1 of 9: the
  accrual-threw refund test fails with Error: connection lost, the rejection
  escaping, not an assertion. The test's own comment said so; the claim did not.
- author-fee-accrual.service.ts said every MONEY-MOVING entry point is behind
  the flag with reverseBlockAuthorFee as "the exception". It moves money — it
  refunds via createBuzzTransactionMany. Accurate form: every entry point that
  can CREATE an obligation is behind the flag, and the reversal deliberately is
  not. Also corrected "on every terminal poll": it does not fire on succeeded.
- settleBlockAuthorFees({ date }) is a public parameter and the disjointness
  property holds only while no caller passes a date AHEAD of the reverser's
  clock. The single production caller is correct; the precondition is now
  documented on settlementBoundary and on the field.
- Two nits: the eligibility log line no longer defends against a non-Date
  accruedAt one line after isSettlementEligible called .getTime() on it
  unguarded, and the count < 1 branch says why it shares reason 'no-accrual'.

Tests: 84 across the four author-fee suites, up from 83 at 29ee5a8a42. The one
new test is RED at 29ee5a8a42 (1 failed / 20 passed in that file) on its own
declaration assertion, GREEN at head; its structural half was shown reachable by
dropping the whatif clause from the pass-through guard. Everything continues to
mock Prisma and the Buzz service at the module boundary — no claim is made about
real-database or live-Buzz behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(app-blocks): attach the no-accrual note to the result type, not to `reason`

Prettier hoisted the union first member's docblock onto the `reason` property, so a comment documenting ONE member read as documenting the whole field. Moved to the type's own docblock; no other change.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(app-blocks): give the whatif-declaration guard a positive control

Every assertion in it sits inside a loop over NO_FEE_PATHS, so an emptied ledger would make it pass having checked nothing. The neighbouring ledger test would also go red in that case, which is exactly why the control is explicit: a guard that only fails because a DIFFERENT guard fails is green for the wrong reason. Verified by emptying the ledger - this test now fails on its own assertion (expected [] to deeply equal [submitCustomComfyWorkflow, submitPassThroughStepWorkflow]).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(app-blocks): correct three overstated claims in the author-fee comments

Round 3 found three prose defects and zero behaviour defects. This pass fixes
exactly those three and changes no behaviour: the only executable edit is the
pinned declaration string the whatif-attribution guard asserts, which is updated
so the test pins the true claim rather than the old overstatement.

F1 — author-fee-accrual.service.ts. The dark-path cost sentence said the
findUnique fires on every terminal non-succeeded observation "plus every
cancel", contradicting its own preceding clause: two of the three observers ARE
the cancel paths (blocks.router.ts:4173 cancelWorkflow, :4399 cancelAppWorkflow)
and both carry the identical compound guard. A reader sizing DB cost from this
counted cancels that issue no query. Now says the two cancel paths carry that
same compound guard rather than calling unconditionally.

F2 — author-fee-charge.service.ts + its test. Both said reaching the
already-settled arm needs the settling app's clock "a whole UTC day ahead" of
the reversing app's, and concluded an operator alerting on its log line "would
get permanent silence". The real condition is
utcDayStart(T_reverser) <= accruedAt < utcDayStart(T_settler): the two clocks
only have to land on DIFFERENT UTC days, not 24h apart. Against the 02:30 UTC
schedule ('30 2 * * *' in jobs/settle-block-author-fees.ts) a ~2.5h lag on the
reversing app's clock suffices, and in the abstract a sub-second straddle of
midnight does — an ordinary NTP failure, not an exotic one. Softened to: the arm
is unreachable under CORRECT clocks, so the line should be near-silent, and if
it fires clock skew is the first thing to suspect rather than the thing ruled
out. Same class of error the previous round fixed — a stated bound wider than
the code's.

F3 — the 'whatif' premise was false and was asserted in five places. Settled by
reading the orchestrator's own source: a real submit can never return a workflow
without an id, and neither can a whatIf. The id is minted before any whatif
branch and stamped twice, in WorkflowsController and again unconditionally in
WorkflowGrain.TryInitializeAsync, which runs BEFORE the EstimateOnly early
return; both response arms return that same grain object. So workflow.id is not
observed absent on any path today, and the exclusion is a no-op defensive guard
rather than an active behaviour change — no attribution row is dropped.

The guard is KEPT: Id is string?, the OpenAPI required list omits id, and
DefaultIgnoreCondition=WhenWritingNull is set globally, so a future regression
would make the field silently vanish rather than error. Only the declared
rationale changes, at all five sites — workflow.service.ts (which was right that
submit carries a real id but wrong in its own premise that a whatIf has none),
both blocks.router.ts comments, and the two NO_FEE_PATHS ledger entries plus the
test's rationale.

The WHATIF_ATTRIBUTION_DECLARATION pin is updated in step so it pins the true
claim. Pin liveness verified in both directions: updating the comments without
the string fails naming submitCustomComfyWorkflow, and updating the string with
one comment left stale fails naming submitPassThroughStepWorkflow — the pin is
live, not decorative.

Tests: 84 passing across the four author-fee suites, unchanged from the baseline
at e1706457e7 (this adds no coverage). Formatted with the repo's pinned prettier
2.8.8.

* docs(app-blocks): retire the whatif-has-no-id premise from its sixth and last site

The round-3 wording pass corrected five sites asserting that a whatif/estimate
workflow carries no orchestrator id. This test's name and comment were the
sixth and were left contradicting the other five.

Settled by reading the orchestrator source: the id is minted before any whatif
branch and stamped twice, the second time in WorkflowGrain.TryInitializeAsync
from the grain key, before the estimate-only early return. Both response arms
return that same object. So the fallback is currently unreachable and this test
pins a defensive floor against a silently-omitted field, not an observed case.

Name and comment only; the fixture, the assertions and the guard are unchanged.
178 passed in this file, prettier 2.8.8 clean.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 21:06:38 -05:00
Justin Maier aed40bc958 fix(reactions): show an unknown reaction count instead of a silent zero (#4962)
* fix(reactions): show an unknown reaction count instead of a silent zero

A reaction count the ClickHouse read could not resolve was indistinguishable
from a real zero by the time it reached the component, and on a card both
rendered as nothing at all: `ReactionButton` drops a zero-count badge when
`noEmpty` is set, which is the default. A metrics outage therefore read as
"nobody reacted".

Carry the distinction as one additive `statsUnknown` flag on `ImageV2Stats`
rather than nullable counts. `getImageMetricsObject` builds all seven fields of
an image from a single ClickHouse row, so a count can never be unresolved on its
own - a per-count shape would be able to represent states the read cannot
produce, and a null could reach `ReactionButton`'s `initialCount + 1` and
surface a fabricated number.

Seven call sites had each derived the stats block by hand as `match?.x ?? 0`.
They now share `toImageV2Stats`, so the absent-vs-zero decision has one
definition instead of seven.

On a card an unknown count renders a "Couldn't load" badge in place of the
empty row; expanded, the badges render an en dash and never a number, including
after the viewer reacts.

Not covered: /api/v1/images builds its stats from event-engine-common's
ImageStats, which has already collapsed an absent row to zero. That path is JSON
only and reached by no rendered feed, so it is pinned to false with a comment;
carrying the flag there needs the submodule shape changed first.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(reactions): cover the metric timeout, gate the unknown badge on a flag

Review of c443a291e4 found the first commit covered only one of
getImageMetricsObject's two failure exits. The outer catch returns {}, so
ids are absent and read as unknown. The soft timeout did not: the loop
after withTimeoutFallback wrote an all-null entry for every requested id,
so `match` was present and a timeout still rendered as a real zero. That
is the documented, instrumented path (the CH read runs ~4.6s p50 against a
3s budget), not an edge case. Both exits now leave ids absent.

The same review found three more gaps:

- The readonly feed card forces `hasReactions` so an unknown count survives
  the readonly early-return, and nothing tested it; deleting the line left
  every case green while restoring the original bug on that surface.
- BuzzTippingBadge renders outside the placeholder branch and read its
  count off the same unresolved row, so an unknown card showed "Couldn't
  load" beside a confident 0. It now renders a dash too.
- toImageV2Stats asserted four of nine fields, so a swapped mapping
  survived; it feeds seven call sites.

The unknown state is gated behind `reactionCountsUnknown` (Flipt key
`reaction-counts-unknown`). Off renders exactly the previous behaviour:
the server still marks the counts, the component ignores them. A missing
flag reads as off, so this ships dark until the flag is created.

Read through `useFeatureFlags` rather than `useOptionalFeatureFlags`: 51
suites mock the provider by hand and list only the former, so importing
the latter killed them at link time with zero tests collected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(reactions): keep the unknown-count flag dark for moderators too

`reactionCountsUnknown` was declared `availability: ['mod']`. The client's
`isEnabledSync` swallows "flag not found" and falls through to that static
list, so every moderator would have seen the badge from deploy, before the
flag existed in Flipt. `[]` keeps it off for everyone until Flipt says
otherwise. A seam test now runs the real evaluator with only the Flipt edge
stubbed, since the browser tests mock the flag hook and cannot see this.

Also closes three gaps in the kill switch and the failure path:

- The flag-off cases never mounted the tip badge, so a tip that read the
  raw server mark instead of the gated one passed them all. They now pass
  `targetUserId`.
- No case combined flag-off with `readonly`.
- The outer catch's `{}` was claimed in a comment and pinned by nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(reactions): prove the unknown-count flag is dark for every audience

The seam test was named for "everyone" but built only free users, so any
paid-tier availability (`member`, `founder`, `bronze`, `silver`, `gold`)
or `granted` would have leaked the badge to that audience at deploy with
every case green. The static fallback matches `user.tier` exactly, so one
paid user does not stand in for another; each tier is its own case, each
also a moderator holding an explicit grant, plus an anonymous visitor.
All nine non-empty availability values the fallback understands now turn
at least one case red.

The file also now primes the lazily loaded Flipt module, as its peer seam
tests do, so the OFF cases exercise "Flipt says the key does not exist"
rather than "Flipt not loaded yet", and the moderator case pins that Flipt
was asked for this key.

The flag-off + readonly browser case now asserts the row has no children,
so it no longer depends on the tip badge being mounted to see a regression.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 20:05:06 -06:00
Justin Maier 46ee3b5fa9 fix(posts): render the drafts/scheduled feed in publish order (#4963)
* fix(posts): render the drafts/scheduled feed in publish order

Masonry places each card in whichever column is currently shortest, so the
server's descending publish order only survives down a column. Read across a
row it comes out scrambled, which is what the reporter saw: 6h, 7h, 8h on one
row then 5h, 4h, 3h on the next.

Swap the draftOnly feed onto MasonryGridVirtual, the row-major virtualized grid
articles, models and bounties already use. It consumes the same MasonryProvider
the page mounts, so nothing else moves; the published feed keeps masonry.

A scheduled post also showed no time at all, only a clock icon whose tooltip
said "Scheduled". It now shows the time itself, and an unscheduled draft says so
rather than showing nothing. DaysFromNow ticks every 15s per mounted card;
Countdown ticks every second under an hour, which is where a publish queue sits.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(posts): fill the card with its media, and tighten the order tests

Review found the media was never constrained to the cell the grid sizes: the
card link is a flex item with no flex-grow, so its height came from the image
and `.image`'s height: 100% resolved against auto. A landscape draft left dead
background under the image with the badge floating in it, and a portrait one
clipped harder than masonry ever did.

Tests, all with the mutation that reddens them:
- assert row-major GEOMETRY, not just document order. `direction: rtl` on the
  row keeps the ids in sequence while the queue reads backwards, and printed
  `expected [ 666, 350, 34 ] to deeply equal [ 34, 350, 666 ]`.
- pin masonry's actual permutation on the published feed instead of "not the
  server order", which accepts any other wrong grouping.
- bound the mounted window from below as well: a collapsed one row window is a
  feed that goes blank on scroll and satisfies every upper bound.
- ten posts, so the grid's ragged last row is exercised.
- cover the badge itself, which the ordering test mocks away. Forcing it to
  always say Draft prints `expected 'Draft' to contain 'in 3 days'`; forcing it
  always on prints `expected 'Draft' to be null`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(posts): order the drafts view as a publish queue and drop its sort picker

From UI review: the drafts view read soonest-publishing LAST, because it
inherited the feed's Newest/Oldest sort (drafts first, then scheduled posts
furthest-out first). The picker does not mean anything for a queue, so the
drafts view now has one fixed order: unscheduled drafts newest first, then
scheduled posts soonest first, and the page no longer shows the picker there.

Those are two directions and the keyset cursor takes one, so scheduled times
are mirrored about the epoch into a single descending timestamp key, below every
draft (which sit a millennium up). Checked against the dev database for a user
with 245 drafts and 510 scheduled posts: rows 1-245 are drafts newest first,
row 246 onward is the queue soonest first, and a keyset page taken across that
seam re-serves the cursor row and continues without a skip.

Also from review of the previous round: the row-major check selected the first
row by shared `top`, which let a layout flowing DOWN the columns pass, since the
head of every column shares the first card's top. It now takes the first row by
DOM index, and a case feeds it a CSS-columns fixture to prove it is rejected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(posts): keep the drafts queue key in range, and make its test see the SQL

Review of the previous commit found three things.

The mirrored-timestamp key left timestamp range for a far-future publishedAt
(nothing bounds it on write; only the schedule modal caps it, client-side):
year 9999 raised `timestamp out of range`, taking down the owner's and any
moderator's drafts tab. The key is now epoch milliseconds, negated for scheduled
posts and lifted by 1e15 for drafts, as float8 (exact below 2^53, which a year
9999 value is). Re-checked on the dev database: same order, and a keyset page
across the draft/scheduled seam continues without a skip.

`?section=draft&sort=Recently Added` began to 400 once the client stopped
rewriting the sort; the drafts branch now runs before the Recently Added check,
and the service guard skips drafts.

The keyset suite could not see an edit to the SQL: its evaluator matched on the
imported constant, so any rewrite still hit that case and checked the model
against itself. It now matches a literal copy. Flipping the scheduled sign in a
copy of the module prints `post-sort pager cannot evaluate sort expression`.

Also: the CSS-columns fixture now puts more cards in column one than there are
columns, and a control asserts the previous top-based check accepts it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(posts): say what the keyset pager's literal SQL label does and does not check

The label pins the drafts-queue SQL's spelling; nothing in the suite executes
it. Say so where the next editor will read it, so a pasted label is not taken
for a re-verified query.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:56:16 -06:00
Justin Maier 9de45506cf refactor(shop): narrow shop item payloads to the fields the client renders (#4969)
Every surface that shows a shop item to a buyer publishes its meta through one
display list, `shopItemDisplayMeta(meta, soldCount)`, with `purchases` taken
from the purchase-row count. The cosmetic-store editor reads are
moderator-only with the default token scope and keep the full record, which
the moderator form writes back.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:48:59 -06:00
Justin Maier 7e15bca183 chore(clickhouse): upgrade @clickhouse/client from 0.2.10 to 1.23.1 (#4972)
Upgrades @clickhouse/client from 0.2.10 to 1.23.1 in the root package.json and
packages/civitai-clickhouse. apps/event-engine was already on 1.x, so the workspace
now holds one version of the driver.

A version upgrade, not a fix for the ClickHouse socket hang-ups. The pre-upgrade
rate was recorded before this change so the post-deploy rate can be compared.

- ResultSet.json<T>() returns T[] in 1.x: 13 call sites, 3 in src/ and 10 in
  apps/moderator, which the root typecheck does not cover.
- host -> url; keep_alive.socket_ttl + retry_on_expired_socket -> idle_socket_ttl.
- Three 1.x default changes held at their 0.2.x values: max_open_connections
  Infinity, request_timeout 300000, response compression on.
- keep_alive.eagerly_destroy_stale_sockets: true is a deliberate non-default,
  more permissive than 0.2.x's retry, standing in for it. It confounds the
  before/after comparison.
- 1.x adds ~1ms per request (await sleep(0)); not configurable.
- apps/moderator ships on its own release.
- New src/server/clickhouse/__tests__/client-config-pins.test.ts pins the values.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 19:22:38 -06:00
Justin Maier 9282fd5deb fix(shop): read the sold count from the purchase rows, not the meta counter (#4942)
* fix(shop): read the sold count from the purchase rows, not the meta counter

A listing had two live answers to "how many sold". `_count.purchases` counts
real purchase rows and is what the sold-out gate, the quantity floor, the
delete guard and the MostPopular sort use. `meta.purchases` is a denormalised
JSONB counter, and it was what every displayed count read.

They disagree on 47 of 1,902 prod listings. One of those renders "20 remaining"
on a sold-out item behind a buy button that throws.

Two selects gain `_count` — the shared `cosmeticShopItemSelect` and
`getPackDetail`'s own, which does not use it. Four sanitizers emit the row
count. Three further paths return `meta` to the client as-is and have no
whitelist to change, so `withSoldCount` writes the row count onto the key they
read; without it the same ShopItem component would show a correct number on a
creator storefront and the drifting one on /shop.

The counter is still written and nothing is backfilled. Fixing the writer is a
separate PR.

The index is required and goes in BEFORE the deploy — not because a reader
would 500 without it, but so the first shop page after the deploy is not the
one that discovers the seq scan. Applied by hand.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(shop): keep the sold-count change a read, and pin the selects that carry it

Review round on #4942. Three real defects in the first pass.

The change was not read-only. `getShopItemById` seeds the moderator item
editor, that form posts the whole `meta` object back, and `purchases` is a
declared key so zod keeps it — so every save wrote the derived row count into
the stored counter, from a client cache that can be older than the value it
replaced. The update now keeps what is stored; only a purchase moves it. The
response still reports the rows, like every other read.

Both `_count` select lines were pinned by nothing. Prisma mocks ignore
`select` and every fixture hand-writes `_count`, so deleting either line left
the whole suite green and threw on six read paths in production. Asserted
against the query the code emits.

The migration and schema comments measured a plan Prisma does not produce. A
relation `_count` is a LEFT JOIN to one whole-table GROUP BY, not a correlated
per-row subquery: 13.7 ms and 1,190 buffers, not 140 ms and 71,400, and the
cost does not scale with page size. Rewritten with the real plans, where the
index actually pays (single-item reads), and what it does not fix.

Also: `getSectionById` and the upsert return were the two paths still serving
the counter; both now go through `withSoldCount`, with controls. The
`StickerShopPanel` comment justified its non-interleaved shelves on a sort-key
mismatch this change removes.

Every guard here was reverted and re-run; each fails naming the wrong value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(shop): pin the selects and the response the last round left unguarded

Second review round, run mutants rather than argued ones. Three things the
fix round itself introduced.

Two more selects were pinned by nothing, the same shape as the two the last
round closed: deleting `meta: true` from the existing-item select left every
write-back test green while every moderator save would write `purchases: 0`
over the stored counter, and redefining `_count` on `creatorStorefrontItemSelect`
killed the sold count on the creator storefront and the community hub with
nothing red. Both now assert the query the code emitted.

Tightening one test replaced an assertion instead of adding to it, and the
behaviour it covered went in the same change — so the upsert response was
unpinned in both directions. It is deliberately NOT mapped through
`withSoldCount`: its only consumer invalidates and discards the payload, so
mapping it fixed nothing and pinned a value nobody reads. That decision is now
recorded by an assertion rather than left to the next reader.

The migration header claimed the index would let multi-row paths read the index
instead of the heap. It will not: every buffer is a `shared hit`, the table is
fully cached, and `relallvisible` is 550 of 1,190 pages. Also names
`getCommunityCosmetics` as the heaviest consumer — under MostPopular it carries
two whole-table aggregates, confirmed by reading Prisma's emitted SQL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(shop): correct the pin comments, cover the create path, drop a dead aggregate

Round-3 review findings.

Three comments claimed the test assertions were the only thing holding a
`select` line. They are not: Prisma narrows the row to the select, so dropping
a field is `TS2339` at the read. Reworded to what is true — a fast readable
second signal, and the condition under which it would become the gate.

The create branch's `purchases: 0` was covered by nothing: deleting it leaves
603 tests and typecheck green, because `meta` is Json. A new listing has sold
nothing and `purchases` is client-supplied, so the zero is imposed, not trusted.

Removing `withSoldCount` from the upsert response left the `_count` in that
transaction's select dead — a whole-table aggregate on the primary, inside an
open write transaction, that nothing reads. The write path now selects without
it.

The index migration is marked NOT APPROVED: the owner's answer was to replace
the Prisma query with raw SQL first and re-measure, since the doubled aggregate
is a Prisma artifact rather than a database necessity.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:15:19 -06:00
Justin Maier a658c3f6df perf(dev-server): let the test queue cap each run's vitest pool (#4965)
* perf(dev-server): let the test queue cap each run's vitest pool

The queue serialises full-suite runs at concurrency 1, which makes a run wait
behind every other agent's. Measured over 23.4h of the daemon's own history
(50 runs, 12 worktrees): median run 549s, median wait 186s, mean wait 405s,
worst 2247s.

Raising concurrency is the only lever that helps a change whose closure reaches
the hot services, but it cannot be raised alone: vitest sizes its pool at
`cpus - 1`, so two uncapped runs ask for 62 workers on a 32-core box.

VITEST_MAX_WORKERS cannot carry the cap here — the daemon spawns the child with
the daemon's own environment, so the caller's copy never arrives and the
daemon's is fixed at start. The CLI flag is the only channel that reaches a
queued run, and it is forwarded through `pnpm run` into vitest.

Verified by pool id rather than by argv alone: 8 files at --max-workers=2 ran
on workers [1 2]; the same 8 uncapped ran on [1 2 3 4 5 6 7 8].

Adds a runtime setter beside it so the width can be tuned without a second
daemon restart, and each key of `test config` is applied only when sent — a
concurrency change must not silently drop the cap.

Also replaces the "~75s" figure in the full-suite hook, which was off by 7x
against the measured median and was what every agent budgeted against.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* perf(dev-server): queue full typechecks in their own lane

A full `pnpm run typecheck` is one core and up to an 8 GB heap, and several
agents starting one at once pegs the box the same way concurrent suites did.
With CIVITAI_TEST_QUEUE set it now goes through the dev-server queue.

The queue gains run kinds with separate limits rather than one pool, because
the loads differ: a suite saturates every core, tsc is effectively
single-threaded. One shared limit would either hold a typecheck behind every
queued suite or let two suites run at once. Each lane takes only its own head
of the queue, and a run's position is reported within its lane.

The scalar concurrency every existing caller passes still sets the unit lane
only; reading it as "every lane" would raise the typecheck limit on any machine
that had only ever tuned the suite.

A typecheck stays direct in CI, with any argument (the scripts gate's
`-p tsconfig.scripts.json`), with the tsc test seam in use (otherwise the
typecheck tests would queue behind real runs and assert on the daemon's REAL
tsc), and with a heap override (a queued run gets the daemon's environment, so
the override would be silently dropped).

typecheck.mjs reuses test-unit-run.mjs's queue client rather than a copy.

Also fixes the worker cap missing a caller's camelCase `--maxWorkers`, which
vitest treats as the same flag — the queue would have appended a second,
conflicting width after it.

Nine revert controls, each red on its own named test, restores verified by
hash — including the one nothing else catches: a typecheck posted without its
kind is accepted as a unit run and spawns a full suite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* feat(hooks): send full-program tsc runs to the queued typecheck script

A direct `npx tsc --noEmit` skips the typecheck lane, so agents doing it at
once are N single-core 8 GB heaps pegging the box. It is also the wrong check:
tsc at node's default heap can abort part-way with zero diagnostics and a log
that reads clean. scripts/typecheck.mjs raises the heap and names that crash.

The hook now denies a full-program tsc (no -p, or -p at the root tsconfig)
and points at `pnpm run typecheck`. Narrow runs pass untouched: a sub-project
(`-p tsconfig.scripts.json`, which the scripts gate itself recommends), named
files, --build, and informational flags. TYPECHECK_DIRECT=1 opts out for
diagnosing tsc itself.

Selftest: 68 rows green. Controls: disabling the guard fails all 10 block rows;
matching `tsc\b` instead of `tsc(?=\s|$)` fails only "tsc-alias is not tsc".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 18:11:39 -06:00
briant 1299d1099f 5.1.113 v5.1.113 2026-09-18 17:28:25 -06:00
briant b9ce4a81b0 fix(generation): ungate YuE2 and lock the audio model picker
YuE2 was hidden behind the mod-only yue2Generator flag, so non-mods never
saw it in the audio picker, and the model page's Create button fell back to
ACE because a hidden ecosystem is dropped at the ecosystem field. Remove the
flag from both lanes.

The form-graph audio form also never passed modelLocked through to the
resource select, so locked audio models (ACE, MiniMax Music 3, YuE2) showed
a swap control. Pass allowSwap={!meta?.modelLocked} as the image and video
forms already do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:22:31 -06:00
Briant Diehl 12aa477882 Merge pull request #4964 from civitai/feat/huggingface-import-api
Feat/huggingface import api
2026-09-18 16:52:56 -06:00
Zachary Lowden f196799338 fix(app-blocks): a publisher ban revokes their live block instances (#4947)
* fix(app-blocks): a publisher ban now revokes their live block instances

Banning a publisher already unpublished their models, cancelled the
subscription, blocked their media and invalidated their sessions — but it
wrote none of the three markers the block-token runtime guards read, so
every block token their apps already held kept authenticating against the
REST wrapper and the tRPC bridge until its natural exp (900s default,
14400s for a dev token). That residual, and only that residual, is what
this closes.

Adds the third production writer of BlockRevocation.revokeInstance:
revokeBlockInstancesForPublisher (blocks/publisher-ban-revocation.service.ts),
called from toggleBan's ban fan-out alongside the model unpublish and the
media block. It marks every live instance of every block the banned user
OWNS (app.userId).

Deliberately narrow, both ways:
- Owner only, never a seated collaborator. Widening it would let a ban on
  one account revoke the live tokens of an app owned by another account
  that was not banned. Recorded in the app-ownership gate ledger.
- No enabled filter. A disabled install's earlier marker is TTL-bound and
  may have lapsed; re-marking costs one Redis SET and only narrows exposure.
- The unban branch clears nothing. The markers expire with one token
  lifetime and re-minting is the recovery path.
- isRevoked still fails OPEN on a Redis error — untouched, and now pinned
  by a live assertion rather than left to a diff review. Inverting it would
  refuse every block during a Redis incident; that is a separate decision.

The guard comment at block-scope.middleware.ts is cited by five other files
as the authority on this behaviour and said a ban writes none of the three,
so it moves with the code — along with block-revocation.service,
block-bridge-auth.service, apps.router, apps-shared.router,
scope-grant.service and the bridge-token guard test's header.

Closes clawgate #618.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): reach all five instance-id namespaces, and make a ban marker unclearable

Review round 1 (civitai-review, five lanes) found the first cut of this writer was
wrong in the direction its own comments denied.

1. A blockInstanceId is not always a stored column. Four of the five namespaces are
   SYNTHESISED: bus_pub_<busId> and bus_view_<busId> off a blanket subscription's
   row id, pdb_<appBlockId> and page_<appBlockId> off the app block. The original
   `where: { blockInstanceId: { not: null } }` reached ONE of five, and its comment
   justified that with "a blanket subscription has no minted token keyed to a stored
   one" — true, and irrelevant: the token is keyed to the synthesised id and
   isRevoked compares claims.blockInstanceId verbatim. publisher_all_my_models
   blanket is the publisher's own default install shape, so the most likely case was
   the one it missed. The writer now enumerates subscriptions AND owned app blocks
   and emits all five.

   Pinned by a new seam guard, publisher-ban-revocation.namespaces.test.ts, against
   deriveScopeFromInstanceId — the canonical parser. Fails on GROWTH (the parser
   learns a prefix the writer does not emit) and on SHRINK/typo (the writer emits one
   no token carries). Four mutations watched red, each on its own assertion.

2. A ban marker was clearable by a third party. clearInstance is called
   unconditionally by toggleEnabled(true) and installOnModel, both driven by the
   install's CONSUMER — the model owner, a different and un-banned account — and
   blockInstanceId survives a disable. Toggling off and on undid the moderation
   action. The marker now carries its cause as its value ('install' | 'ban'; the
   legacy '1' reads as install) and clearInstance refuses a ban marker. It fails
   CLOSED on a read error, the opposite of isRevoked and deliberately so: an
   un-cleared marker expires within one token lifetime, a wrongly-cleared ban marker
   needs a second moderator action.

3. Three mutations survived the first test suite, all found by running them:
   - the Redis fake resolved in a microtask, so dropping the await on the fan-out and
     detaching the leg from toggleBan's awaited Promise.all both passed. AC-1 is a
     happens-before claim and the fake could not express it. Every fake now yields a
     macrotask tick; both mutations are killed.
   - "wrote a marker for each id AND FOR NO OTHER" filtered the key set to the
     fixture's own ids, so over-revocation was invisible — the exact hazard the
     ownership filter exists to prevent. It now asserts the whole key set plus the
     call count; a mutant revoking a foreign id is killed.
   - mint() hardcoded one appBlockId, so the fixture's "across two blocks" claim was
     not exercised. It now carries each row's own.

Also: the chunk-and-await loop is replaced by limitConcurrency (the repo's helper) —
the old shape was a barrier, not the ceiling its own docblock described; the returned
count is now logged rather than discarded; and a call-site ledger pins the "exactly
three production call sites" sentence, which five files restate and which those same
comments record as having already been wrong twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): split the ban keyspace, route ownership canonically, make revocation observable

Audit round 1 findings F1-F5, plus the operator-approved counter. One commit so round 2
audits a single delta.

🔴 F1 (deploy-blocker) — the cause-on-one-key design did not hold. `toggleEnabled(false)`
calls `revokeInstance` with no cause, and the write was unconditional, so an ordinary
un-banned model owner disabling a banned publisher's install DOWNGRADED the ban marker to
`install`; `toggleEnabled(true)` then cleared it and the pre-ban token was served again.
`clearInstance` had been hardened; the write in front of it had not.

Fixed STRUCTURALLY rather than with a value guard: ban markers get their own Redis
keyspace (`blocks:revoked-instance-ban`) written by a separate method,
`revokeInstanceForBan`. A read-then-set would have been a read-modify-write with a race
in it, and this wrapper exposes no value-guarded SET; separate keys make the downgrade
UNREPRESENTABLE — the install path cannot name the key. `isRevoked` checks both in ONE
round trip via `mGet`, so the per-request cost claim in block-bridge-auth stays true.
`clearInstance` addresses the install keyspace only and needs no branch at all.

And the guard that hid it: the consumer-re-enable test modelled `clearInstance` ALONE,
omitting the `revokeInstance` leg the real pair always runs first, so it was green in both
arms. It now drives the REAL `BlockRegistry.toggleEnabled(false)` then `(true)` pair, with
a positive control that an UNBANNED publisher's install still restores.

🟡 F2 — "re-minting is the recovery path" was false, including in this card's AC-3.
Instance ids are stable across a re-mint, so after ban→unban a freshly minted token was
still 403 for up to 14400s with no product-level remedy. The unban branch now clears the
ban keyspace for that publisher's instances, through the SAME enumeration the ban used
(a clear addressing fewer ids is no remedy). Install markers are untouched, so lifting a
ban cannot silently re-enable an install its own consumer switched off. The three comments
and `block-registry.service.ts`'s re-enable comment now agree.

🟡 F3 — `page_` is three mint shapes, not one. Added `page_pubreq_<publishRequestId>` via
`appBlockPublishRequest` (submittedByUserId + pending, mirroring the mint). `page_local_*`
is DOCUMENTED as uncoverable: it exists precisely because no server row ties the slug to a
user, so there is nothing to enumerate — closing it needs a different mechanism, not a
wider query.

🟡 F4 — the seam guard pinned `deriveScopeFromInstanceId`, which its own docblock calls the
client-side path, while the mint dispatches on `BlockRegistry.resolveBlockInstance`. Adding
a 6th prefix to the resolver alone left it green 18/18. It now pins the resolver, the
client parser, AND the two against each other; that same mutation is killed by two
assertions. The call-site ledger is split per writer, so rewriting a ban site to call the
install writer is a red test.

🟡 F5 — settled by enumeration, not assumed. `BlockTokenService.sign` has exactly two
non-test call sites and NO mint path reads `AppListing` or a listing `kind` at all, so an
offsite-owned block CAN mint: mintability and canonical ownership are decided by disjoint
column sets. Ownership now routes through `resolveCanonicalListingOwner` as a three-branch
predicate. Two paths produce the divergence, and only one is a mod action —
`acceptTransfer` moves `OauthClient.userId` only under `isOnsite`. The "offsite 5 rows, 0
with a block" production count is recorded as the stale empirical claim it is, not as a
justification. Pinned by an executable branch-for-branch equivalence test against the real
resolver; dropping branch 1 (listing-less blocks, most of the fleet) and reverting to
`app: { userId }` are both killed on the right rows.

OBSERVABILITY (operator-approved, pre-existing gap) — the revocation 403 returns before
`recordScopeInvocation` registers its `res.on('finish')`, so it could never write a
`block_scope_invocations` row and the mechanism's firing was unfalsifiable. Adds
`civitai_app_block_revocation_refusals_total{surface,namespace}`, emitted from both guards.
The namespace label is bounded and would have made F3's gap readable — `page_pubreq_` and
`page_local_` are bucketed before `page_`, which is the collapse that hid them.

Not in scope, deliberately: ban durability beyond one token lifetime is clawgate #620 and
is layered on these markers rather than replacing them (the status flip is replica-read and
lag-delayed; these kill a live session at Redis speed). The two eventloop-watchdog timing
failures are pre-existing on origin/main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): cover the ephemeral + mod-review page shapes, guard the ban TTL, correct three false claims

Audit round 2: four 🟡 and one 🟢. One commit so round 3 audits a single delta.

🟡1 — the `page_` enumeration was incomplete and the narrowing was false. It is FIVE mint
shapes, not three, and TWO more were uncovered, both `dev:true` (4h):
  - `page_ephemeral-<blockId>` (block-tokens/index.ts) — an unsubmitted app over a live
    dev tunnel, held by the AUTHOR: precisely the publisher this control exists to cut
    off. Filed as uncoverable last round; that was wrong. An ephemeral app has no
    AppBlock row, but a live tunnel IS server state. dev-tunnel.service.ts now maintains
    a per-user SET of tunnelled blockIds (`listActiveDevTunnelBlockIds`) so a ban can
    enumerate them — `userBlockKey` can only answer "is THIS pair live", and the
    alternative was a cluster-wide SCAN inside a fan-out Bulk Ban multiplies. Index
    writes are try/catch, not a trailing `.catch()`: a missing method throws
    SYNCHRONOUSLY and would otherwise take a developer's tunnel start with it — which is
    exactly how the dev-tunnel suite broke the moment those lines were added.
  - `page_<pubreq_ULID>` (publish-request.service.ts) — the MOD review preview, SINGLE
    `pubreq_`, so dev-token's double-prefixed spelling never matched it. Both spellings
    are now emitted from the same pending rows.
`page_local_<slug>` remains genuinely uncoverable and is documented as a statement about
the mechanism: no server row of any kind ties that slug to a user. Both the writer's and
the middleware's shape claims are corrected; the middleware no longer restates a count.

And the seam guard's structural blindness is closed rather than just confessed. Every
prefix ledger is blind to a new mint surface that REUSES an existing prefix —
`deriveScopeFromInstanceId('page_ephemeral-foo')` returns `viewer_global` via the bare
`page_` branch — which is how `page_` came to be documented as one shape when it is five.
Added a MINT-SITE ledger over the four files that CONSTRUCT a blockInstanceId, with each
one's coverage. Verified: a sixth `page_` shape added to an unledgered file leaves all
prefix assertions green and fails the mint-site ledger. Its residual (an EXISTING file
growing a new shape internally) is stated in its docblock rather than left implied.

🟡2 — a surviving mutant on a safety-critical invariant. The TTL suite probed only
`revokeInstance`, so `revokeInstanceForBan`'s EX was guarded by nothing: mutating it to
900 stayed green across 5 files / 116 tests while a banned publisher's dev token lives to
14400s — silently un-revoked from T+900s. `markerTtlSeconds` is now parameterised over
both writers as a cross product with the token kinds, plus a ledger that reads the
service's own `static async revoke*` surface so a THIRD keyspace cannot ship unprobed the
way the second did.

🟡3 — "ONE ROUND TRIP, NOT TWO" was false and is deleted, not repaired. This repo's client
wraps `mGet` into `Promise.all(keys.map(get))` to avoid CROSSSLOT, so the array path never
reaches the native MGET: it is two GETs. Wall-clock is likely unchanged (same tick,
pipelined) but the COMMAND RATE against the cache cluster is doubled on every REST and
bridge request. Reworded here and in the four stale "ONE Redis GET" lines in
block-bridge-auth. The read logic itself is untouched — it was verified correct.

🟡4 — the new counter had zero test coverage while its own docblock called the branch
order load-bearing. Added a suite pinning each real minted shape to its label plus a
structural check that no prefix is tested after one it extends. Round 2 could not run this
mutation; it runs now and is killed by four assertions.

🟢5 — an inverted empirical claim: "most app blocks predate W13 and have no listing row"
is 23-of-24 the other way. The conclusion (keep branch 1) is unchanged — that single
listing-less block is the entire reason — but the magnitude is corrected and labelled.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): subject-scope the ephemeral ban marker, make the index write atomic and owned

Audit round 3: four 🟡, two 🟢, plus one out-of-range stale sentence. One commit so round 4
audits the delta from b3d0e22f5a.

🟡 R3-2 — A BAN 403'd AN INNOCENT AUTHOR. `page_ephemeral-<slug>` is the one instance id
that is NOT globally unique: the slug is developer-chosen, `resolveEphemeralDevPageBlock`
checks it only against AppBlock rows and pending requests (never against tunnels), and
`startDevTunnel` enforces uniqueness per (user, blockId) ONLY. Two authors can hold live
tunnels on the same unclaimed slug and both mint `page_ephemeral-demo`; banning one wrote
a GLOBAL marker that refused the other's own dev tunnel for up to 4h. Ban markers for this
shape now go to a SUBJECT-SCOPED keyspace and `isRevoked` consults it with the token's own
`sub`, so the ban lands on the holder it names. Every other id is derived from a unique row
id and stays global — those tokens are legitimately held by many viewers and all of them
must be refused. This also makes the reaper's orphan branch benign: it has no session
record to read a userId/blockId from and so cannot sRem, but a stale member now yields a
marker only under its own owner's key. The docblock that claimed the id "carries no other
user's token" is replaced by what is actually true.

🟡 R3-1 — took the helper. `sAdd` + `expire` in one swallowing catch is the racy pair
`sAddWithExpireGe` was written to replace; its own docblock says the EVAL form means a
crash cannot land SADD without EXPIRE. The failure it prevents is documented on this exact
client (sys-inflight.ts, 2026-07-03): a sentinel failover landing one and dropping the
other leaves a TTL-less set accumulating every blockId the user ever tunnels, so every
later ban emits a marker for all of them — and the index's own TTL guarantee would have
been false in that state. Kept the try/catch (not `.catch()`): a missing method throws
synchronously and must not take a tunnel start with it.

🟡 R3-3 — the mint-site ledger was walkable three ways, all now closed, each with a matched
pair: (a) it walked `.ts` only, while the REAL constructors are `.tsx` — so the very shape
this PR covered was minted in a file the population excluded; (b) MINT_RE matched
`PAGE_INSTANCE_PREFIX}`, which in block-tokens/index.ts occurs only in `!==` COMPARISONS,
so that file was ledgered as constructing two shapes it does not construct; (c) it ran over
raw source, so one prepended comment satisfied it. Comments are now stripped through a
single shared helper — the ad-hoc second copy is how the discipline was lost. The residual
sentence now describes what ships instead of being three ways too narrow.

🟡 R3-4 — the index write was payload nothing could fail on: deleting it left 707 files /
12,806 tests green, because both sides were hermetically tested and the seam was owned by
nobody. Five tests now drive the REAL startDevTunnel/teardown pair through
listActiveDevTunnelBlockIds; deleting the block fails three of them. The suite's fake gained
an `eval` that EXECUTES the script's effect rather than returning a canned reply — a stub
would let the assertions pass with the index never populated.

🟢 R3-5 — `page_pubreq_` was tested before `page_pubreq`, making the mod-review branch
unreachable so both spellings printed the dev-token label. Discriminates on the second
`pubreq_` now. 🟢 R3-6 — the SHRINK/TYPO guard extracted with `[a-z_]+`, which cannot match
`page_ephemeral-`; with the hyphen it can.

Also: the third surviving copy of the corrected "three mint shapes" sentence, in the file
that defines APP_BLOCK_REVOCATION_NAMESPACES.

Two spelled guards over the call shape correctly went red on the new second argument and
are updated to require it — `isRevoked(claims.blockInstanceId, claims.sub)` — since
dropping the subject type-checks (the parameter is optional so a caller without one
degrades rather than breaks) and silently un-scopes the ban.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): pass the subject at all four guards, and make three claims true of the code

Audit round 4. Last pass of the ladder, so every claim below was re-checked rather than
carried.

🟡 F2 — the only one with a behavioural consequence. `isRevoked` has FOUR production call
sites, not the "both guards" the docblock claimed, and two of them — `apps.router.ts`
(resolveStorageContext) and `apps-shared.router.ts` (resolveSharedContext) — passed no
subject, so they read only the global keys while the ephemeral ban marker exists solely
under the subject key. Latent today (both require an `approved` AppBlock row and an
ephemeral app has none) but pointed exactly where this work keeps going: storage for
unsubmitted apps. All four now pass `claims.sub`; the sentence names all four and says why
omitting it is silent — it NARROWS the check rather than breaking it.

🟡 F3 — made the guard see sub-shapes rather than narrowing the sentence a third time.
Widening the extraction to `[a-z_-]` put `page_ephemeral-` into `emitted`, but the
assertion it fed was "does the parser recognise this prefix", and the parser dispatches on
`page_` — so `page_ephemral-`, a one-letter typo, stayed GREEN. The emitted set is now
compared as a SET against the shapes the writer is supposed to build, which fails on a
prefix that appears AND on one that vanishes. The typo mutant is red; the `pdb_` → `pdx_`
positive control still reds the parser check too.

🟡 F1 — the docblock named a round-trip suite that did not exist: `subjectForUserId`
appeared in two files, neither a test, and the only thing holding the spelling was a
hand-typed `:user:<id>:` literal in the ban suite. Wrote the suite it promised —
`subjectForUserId` through `parseSubjectUserId` and back, with a positive control that the
parser rejects spellings this function cannot produce — and pointed the sentence at it
while crediting the literal for what it does pin.

🟢 F6 — took it. The third GET is gated on `isSubjectScopedInstanceId`, so only
`page_ephemeral-*` pays it; everything else, and every anon subject, is back to two.
🔴 The predicate is ONE function shared by the reader and the ban writer, because the
drift it prevents is silent in the worst direction: a writer that scopes while the reader
does not look leaves an unreadable marker — a ban that refuses nobody with every key-level
assertion still green. The writer asserts the predicate agrees before using the scoped
bucket, so that divergence throws at the one place both sides meet.

🟢 F5 — the third stale round-trip count, in the copy I missed last round. Fixed, and
recorded that this number has now been wrong three times across three files: if it goes
wrong a fourth time the count should be deleted rather than corrected, since the ordering
argument it supports does not need it.

🟢 F4 — `evalTtls` was written, never read, under a comment promising TTL coverage. It now
asserts the floor the service passes, and its comment says what it cannot do (model the GE
comparison — the fake has no TTL clock).

One spelled guard in `apps.router.storage.test.ts` correctly went red on the new second
argument and is updated to require the token claim.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): correct the gate-ledger entry the canonical-owner routing invalidated

Post-merge reconciliation, kept separate from the round-4 fix commit so the diff is
legible.

Merging origin/main surfaced a claim of mine that a previous round's own fix had made
false, in the one shared file nobody flagged. `app-access.call-site-ledger.test.ts`'s entry
for the ban writer still said it "resolves no AppListing, so D5 does not apply" and that
off-site coverage "must go through resolveCanonicalListingOwner" as a future direction —
both written before round 2's F5, which routed the writer through exactly that resolver.
It also still carried "off-site apps mint no block token", the defence round 2 disproved by
enumeration: no mint path reads AppListing or a listing kind at all.

The entry now states what the code does — the three-branch canonical-owner predicate, why
claimListing and acceptTransfer both produce the divergence, and that branch 1 (no listing
→ app.userId) is load-bearing because dropping it would UNDER-revoke most pre-W13 blocks.

MERGE REVIEW, recorded because a clean merge is not a coherent one. The shared file the
merge actually touched is `metrics/app-block-runtime.metrics.ts`, where #4946 adds
`civitai_app_block_bridge_messages_total` and this branch adds
`civitai_app_block_revocation_refusals_total`. Read by eye and checked mechanically: 14
distinct metric names with no name registered twice, no const/function/type defined twice,
no duplicated interface member or return-object key, disjoint label sets, no shared help
text or prefix constant. main's new `block-token-access.service.ts` touches neither
`BlockRevocation` nor any blockInstanceId construction, so neither the revokeInstance
ledger nor the mint-site ledger changes.

Also regenerated the Prisma client: the merge brought `BlockAuthorFeeAccrual` and the stale
client failed typecheck on main's own file, not on anything here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:49:21 -05:00
briant d6d5f856a6 feat(models): a server-side Hugging Face transfer API that attaches on completion
Our own tooling can now queue a repo's files onto a model version in one call
and poll, rather than driving the moderator page by hand. `WEBHOOK_TOKEN`
guards it, so it is the same internal surface as the rest of `api/admin`.

The transfer job attaches each file when its bytes land, which is what makes
one call enough: `attachVersionId` and `attachType` are recorded at enqueue —
their own columns, because `modelVersionId` means "attached to" and detach
clears it. Detach clears the attach target too, or the sweep would re-attach a
file a moderator just removed and mint a second `ModelFile` beside the one
detach leaves alive.

A file whose sha256 we already store is attached without transferring
anything. Hugging Face publishes each LFS file's sha before any bytes move, so
a text encoder shared by a dozen repos costs one lookup we already run. The
match is on the sha and never the filename: `ae.safetensors` names different
bytes in different repos, and the wrong weights on a version stay invisible
until someone generates.

The attach reads the primary. It runs microseconds after its own completion
write, and a replica that had not caught up reported the row as still
transferring — recorded as a permanent failure, which the sweep's `error: null`
filter then excluded from recovery forever.

The sweep takes the same claim the transfer does and runs inside the job's
deadline. Unclaimed, two runs could both pass its read and create a file, with
`linkImportToFile` picking a winner only after both existed.

`createFileHandler`'s body is now `createModelFile`, taking `userId`,
`isModerator` and `track` rather than a request context, because a cron tick
has no session to borrow. The tRPC path passes its own session through.

Both migrations are applied to production. The second is a partial index for
the sweep, which orders by `completedAt` — a column no other index covers.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp
2026-09-18 16:44:35 -06:00
briant 17acf6b062 fix(files): give the two awaited external calls in the create path a deadline
`registerFileLocation` was the only function in `storage-resolver.ts` without
an `AbortSignal` — the three deregister calls already carry one. The model-file
scan submit had none either. Both inherit undici's 300s default, and both are
awaited: by the upload response, and by the Hugging Face import job inside its
own lock. One hung call there holds a lock far past the budget it was sized
for, which is what lets a second run start on work the first still owns.

Registration gets 10s rather than the 30s its neighbours use: it is one small
write a caller waits on, where those are bulk post-commit cleanups nobody
waits on. The scan submit gets 15s, matching the image-ingest submit above it
— that number is sized against a measured ~4.7s P99, and nothing records one
for this call. The submit passes no `wait`, so it is an enqueue and returns as
soon as the orchestrator accepts the workflow.

Neither timeout changes a failure path. A failed registration is already
caught and logged by `safeRegisterFileLocation`; a failed scan submit leaves
`scanRequestedAt` null, so `scanFilesFallbackJob` re-submits within five
minutes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp
2026-09-18 16:44:15 -06:00
Zachary Lowden efa277ebb6 fix(stripe): charge the membership price in the customer pinned currency instead of 500ing (#4945)
* fix(stripe): charge the sibling membership price in the customer's pinned currency

Stripe pins a customer to ONE billing currency the first time they are invoiced
(`customer.currency`) and it is immutable. Every later subscription price for
that customer must be payable in it, or Stripe rejects the call:

  The price specified only supports `usd`. This doesn't match the expected
  currency: `aud`.

That is a raw throw out of `checkout.sessions.create` / `subscriptions.update`,
so it reached the client as a tRPC INTERNAL_SERVER_ERROR (HTTP 500).

The first version of this branch read the situation as "our membership Prices
are single-currency USD, so a pinned customer cannot subscribe at all" and
classified the error into a 4xx. That premise is wrong. Checked against the
Product/Price rows: every active Stripe membership tier carries seven active
monthly sibling Prices — aud, cad, eur, gbp, jpy, krw, usd — exactly one per
currency, and `getPlans` already ships the whole set to the pricing page. The
membership IS purchasable on a pinned account; nothing was resolving which
sibling to charge.

So the remedy is a substitution, not a classification.

Server-side, in `createSubscribeSession`, because that is the only place the
answer is knowable. `customer.currency` is a Stripe-side fact with no column in
our database and no endpoint that exposes it, so the price picker cannot choose
correctly however it is written. Resolving here also covers the plan-change
path and any caller that never goes through the pricing page. The resolution is
scoped to the same product, active, recurring, the same interval and the same
interval_count — each of those narrows a way the substitute could be the wrong
thing to charge, and the product scope comes off the Stripe Price object, which
keeps the lookup inside Stripe's catalog rather than reaching a row belonging to
the other payment provider.

A typed BAD_REQUEST survives only as the genuine fallback, in the two cases
where no single correct answer exists: the membership is not sold in that
currency at all, or more than one active price matches and charging either would
be charging an amount nobody chose. Neither message claims the membership cannot
be purchased — the old one did, and that sentence was false.

Client-side, the plan card now preselects the sibling in the currency of the
member's existing subscription. The server substitutes either way, so this is
not what makes the purchase work; it is so the figure on the card is the figure
that gets charged. That matters most on the plan-change path, which takes the
money immediately with no Stripe-hosted confirmation screen in between. The
existing subscription's price is the only pinned-currency evidence available to
the browser, and it is sound evidence: Stripe accepted that price, so by its own
rule its currency is the pinned one.

Currency case is normalised on every comparison. Not cosmetic here: the
Product/Price tables hold the same currencies in both spellings because the two
payment providers differ (Stripe lower-case, the other upper-case), the plan
card is provider-generic, and Stripe returns `customer.currency` lower-case. A
case-sensitive comparison matches nothing for one of the two catalogs. The
currency dropdown's labels are upper-cased in the data rather than only by the
`uppercase` CSS class, so the label does not depend on which catalog a product
came from.

Regression matrix — the server test was watched fail on pre-change code:

  at origin/main (c6da1a2dc4):  Test Files 1 failed (1) | Tests 11 failed | 3 passed (14)
  at HEAD:                      Test Files 1 passed (1) | Tests 14 passed (14)

The 3 that pass while red are the deliberate controls — the unpinned customer,
the case-insensitive match, and the multi-currency Price — all of which proceed
unchanged before and after.

`pickInitialPriceId` and its 11 tests are new code, so they are an INVARIANT
GUARD, not regression coverage, and are labelled that way in the file.

Mutation sweep, 11 mutants, every one killed, and 9 of the 11 killed by exactly
one test — the one that claims to cover it. Each mutant was applied to a
verified-pristine file and the restore was asserted by digest afterwards, after
a first run silently carried one mutant forward and inflated every later result.

  M1  customer-side toLowerCase dropped        -> the upper-case pin test (+1)
  M2  price-side toLowerCase dropped           -> the both-sides case test
  M3  interval_count filter removed            -> the quarterly-substitute test
  M4  product scope dropped from the lookup    -> the lookup-scope test
  M5  ambiguity resolved by picking the first  -> the refuses-to-guess test
  M6  currency_options expand dropped          -> the expand test
  M7  Checkout charges the requested id        -> the three substitution tests
  M8  currency_options check removed           -> the multi-currency-Price test
  M9  pick: pinned-side toLowerCase dropped    -> upper-case pin vs lower rows
  M10 pick: default-branch toLowerCase dropped -> default differs only in case
  M11 pick: pinned-currency search removed     -> the three pinned-currency tests

Checks: pnpm typecheck 0 errors; 717 files / 12,444 tests passed across
services, Subscriptions and Stripe components; eslint 0 errors on the changed
files; prettier clean.

Not verified here: this has not been exercised against live Stripe, and
`customer.currency` was not read off the failing customer — that needs
credentials and operator authorisation. If the production rejection came from
some other Stripe-side mechanism, the resolver returns early and changes
nothing. One `customers.retrieve` against the id in the original error settles
it.

Out of scope and untouched: `membership-gift.service.ts` creates the recipient's
subscription from the same catalog AFTER the gifter has paid, so the same class
of failure is worse there. Tracked separately. Also untouched: the duplicate and
case-inconsistent Price rows themselves, which are data work.

* fix(pricing): derive the plan card's price selection so a late-arriving currency pin lands

The client-side half of this PR was inert on the page it was written for.

`pickInitialPriceId` was called inside a `useState` initializer, so it ran once at
mount — and on /pricing the card mounts before the subscription is known:

  - src/pages/pricing/index.tsx prefetches subscriptions.getPlans in
    getServerSideProps, so the plans are hydrated on first paint.
  - getUserSubscription is a plain client query (memberships.util.ts), so it is
    undefined on that first client render.
  - MembershipPlans gates the grid on productsLoading alone, so the card renders
    in that window.
  - The only remount vector is key={interval-product.id}, and interval is set from
    subscription?.price?.interval ?? 'month' — a no-op for a monthly member, which
    is exactly the population this PR is about.

So an AUD-pinned member hard-loading /pricing got the USD row preselected, and it
stayed that way for the page's whole life while the rest of the card re-rendered
around it and started offering Upgrade. The server then substitutes the AUD sibling
and subscriptions.update charges immediately with no confirmation screen, so the
amount billed was one the card never displayed.

The selection is now DERIVED on every render by useSelectedPriceId; state holds only
an explicit choice made in the currency picker. Chosen over gating MembershipPlans
on subscriptionLoading (that page prefetches its plans specifically so they paint
without a spinner — gating would undo that for every logged-in visitor) and over
adding the currency to the PlanCard key (a remount discards the user's own picker
choice and re-mounts the card's media).

Regression coverage is behavioural, on the wiring rather than the pure rule:
src/components/Subscriptions/__tests__/useSelectedPriceId.test.ts mounts with the
pin unknown, resolves it, and asserts the shown row follows. With the previous
wiring transplanted verbatim into the hook it fails on that assertion; it also
covers the warm-cache path (pinned row on the FIRST render, no second pass) and
that an explicit picker choice is never recomputed away.
2026-09-18 17:35:24 -05:00
Zachary Lowden c442286109 fix(models): chunk the sale-badge lookup so a scrolled feed stops 400ing (#4948)
* fix(models): chunk the sale-badge lookup so a scrolled feed stops 400ing

`model.getActiveSales` caps `ids` at 500. `useModelSaleBadges` fed it the whole
accumulated list of an infinite feed, so from roughly the fifth page of cards
onward EVERY call was rejected by input validation before the resolver ran. The
sale badge then disappeared from the entire grid for anyone who scrolled — and
because a rejected input is a 400, nothing watching server errors ever saw it.

Chunked client-side rather than raising the cap. The cap is protecting real
per-id work: `getActiveSalesForModels` resolves each id through the per-id
cache, whose `packed.mGet` decomposes into one Redis GET per id on a cluster,
and every id that misses lands in a raw `IN (…)` across a five-table join on the
read replica. The procedure is public, so the length of that array is the only
thing bounding the work — a bigger number would just move the wall.

The cap and the chunk size are now one exported constant, so the client cannot
drift past what the server accepts.

Reuses the existing arrival-order chunker instead of writing a third copy of it.
Moved it from `Sticker/sticker.util` to `shared/utils/chunk-ids` and renamed it
`chunkIds`: two callers already had nothing to do with stickers, and importing
it from there would have pulled the cosmetics/zustand graph into the model feed
bundle. Arrival order is load-bearing, not incidental — sorting reshuffles every
chunk boundary as a feed appends, changing every key and refetching the whole
surface each page.

Regression coverage drives the hook and validates each request it builds against
the procedure's own schema, so a raised chunk size or a lowered cap both fail.
Red at origin/main with `expected [ 1200 ] to deeply equal []` (one request of
1200 ids) and `expected 1 to be 3`; green at HEAD.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(models): keep badges on screen while a chunk loads, and size the chunk to the page

Review round on the chunking fix. Four code findings, all verified against the
source or the installed package before acting on them.

1. `placeholderData: (prev) => prev` is INERT under `trpc.useQueries`, so the
   first cut would have blanked every sale badge in the grid on every page of
   scroll — for feeds under the cap, i.e. exactly the population the original
   400 never touched. Measured against the installed @tanstack/query-core, both
   arms in one run: `QueriesObserver` matches previous observers by `queryHash`
   alone, so a changed key builds a fresh `QueryObserver` whose
   `#lastQueryWithDefinedData` is empty and the placeholder resolves to
   `undefined`; `QueryObserver` (what `useQuery` uses, the positive control)
   keeps one observer for the component's life and does carry it across. The
   option is dropped and keep-previous is done in the hook.

2. Chunk size split from the cap and matched to the feed's page size. The
   trailing partial chunk re-keys on every page, so ids asked per distinct id is
   (chunk/page + 1)/2 — 3.0x at 500/100, 1.0x at 100/100, for the SAME number of
   requests per page. It also keeps a request inside both wire budgets in
   `~/utils/trpc`, so it stays a batchable GET instead of an unbatchable POST.
   The cap stays 500: it is protecting the per-id Redis fan-out, which the schema
   now says instead of blaming the SQL — measured on the replica, execution time
   is flat from 100 to 5000 ids and only planning grows.

3. `endsAt` rode the wire and nothing branched on it — the badge only formatted
   it. A full chunk's key is now stable, so a mounted feed could keep serving a
   sale that had ended, advertising a discount the model page and the charge path
   both refuse. Both hooks re-apply the end edge.

4. Reuse: `discountType` takes `SaleDiscountKind` from the package the server
   declares the output with rather than a local union restatement, and the fourth
   open-coded copy of the chunker folds into `chunkIds`.

The schema module moves to `src/server/schema/`, which is where this repo's tRPC
input contracts live and where the sibling cap constant this mirrors already sits;
the app-graph guard passes with it imported client-side.

Tests: the chunker's coverage moves with the chunker, and the hook's file now
re-renders, so the merge memo key, the partial-load path and keep-previous are
observable rather than asserted into a single synchronous render. Added a seam
guard pinning the router to the shared schema — the drift that reproduces the
outage was previously unguarded. Every guard was watched to fail: nine mutations,
each killed by its own assertion with its own message, restored green after.

The fixture is a literal above the CAP, not derived from the chunk. Sized off the
chunk it sat at 220, under the cap, and the reverted hook was measurably GREEN on
the headline assertion — a positive control on the fixture now keeps that honest.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(models): gate the sale badge on read, and make the cap guard behavioural

Second review round. Two lanes independently found the same defect in the first
round's own fix, and the test lane found that the fix shipped a red suite.

The end-edge check was applied where the merged map was BUILT, which is a stamp,
not a gate. The keep-previous map added in the same commit therefore escaped it
and could re-serve a window that had closed since it was stored — measured at
100 of 100 expired entries returned. The gate moves onto the map being handed
out, and its clock is re-read on every event that changes which map that is:
a chunk arriving, and falling back or recovering. The fallback transition is the
load-bearing one, because it hands out the same object and an identity-keyed memo
would not re-check it — the first attempt at this fix was keyed that way and the
new test caught it.

Residual, stated rather than implied: a feed left mounted and idle re-reads
nothing, so a window closing with no scroll and no refetch stays badged. That was
equally true before this hook chunked — the map was never re-checked at all — and
closing it needs the gate at the per-card read in `ModelCard`, whose only tests
are browser-mode and cannot run in this environment.

Registering the new guard: adding a `no-divergent-*` test without its three
companion entries turns `no-lint-rules-script-drift` red (5 failed / 6 passed,
verified). Wired into the `test:lint-rules` script and both guard inventories —
without which it also never ran under that script at all.

The guard itself was spelled rather than structural and was walkable: declaring a
private `const getActiveSalesSchema` with a lower cap in the router left every
string check green while every request 400d. It now parses real arrays through the
procedure's real parser, so a cap wrong in either direction fails wherever it was
spelled. Four more mutants that survived the previous round now die: `endsAt`
read as a Date only (the string payload the comment describes would throw inside
a render), the single-card hook's end-edge check, the chunk size returning to the
cap, and the merged map losing referential stability.

The fake was also lying in a way that hid two of those: it stamped
`dataUpdatedAt` on every call rather than per resolved id-set, so the merge memo
never memoized under test. It now stamps per id-set and carries a string `endsAt`
arm.

Every guard added here was watched to fail: six mutations, each killed by its own
assertion, restored green after.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(models): say what the id cap actually bounds

The schema comment claimed the cap was "the only thing standing between an
anonymous caller and that fan-out". That is true of ONE request and wrong about
a caller: the cap bounds how WIDE a single call may be, and says nothing about
how many calls arrive. Reading it as an abuse control overstates it.

Both copies of the claim are reworded to state the property the constant really
has — one request's fan-out stays proportional to a page of cards rather than to
an accumulated feed — and the ModelCardContext copy drops the same aside.

* docs(models): narrow the cap guard to what it checks, and decouple a control

Round-2 review found three places where a claim was wider than what backs it.

The cap guard's docstring said the router cap "and the size its card surfaces
chunk to are one contract across two files", but the body never reads the card
surface. Measured: swapping chunkIds(modelIds, MODEL_SALE_IDS_PER_REQUEST) for a
literal 400 leaves no-divergent-active-sales-cap at 4 passed (4), while
useModelSaleBadges goes 3 failed | 15 passed (18). Narrowed the sentence rather
than widening the body -- the only way to read the call site from a node-project
guard is a source-text match, and this file already records that a spelled
version of this guard was written once and shown to be walkable. It now states
its scope first, names the blind spot with both measured arms, and points at the
file that does pin the card-surface half, including that that file runs in the
full unit suite and not in test:lint-rules.

The residual comment in ModelCardContext said "left mounted and IDLE", which
reads as an edge case. refetchOnWindowFocus is false app-wide and the only
per-query override is staleTime, so the end-edge memo re-reads the clock only on
a chunk resolving, a reconnect or a remount -- which means on any surface that
has stopped growing (a profile's OnSaleSection, a search page nobody is paging,
a feed scrolled to the end) the gate freezes for the life of the mount. Stated
as the steady state it is, with the reason a refetchInterval is the wrong lever:
it would re-issue the per-id Redis fan-out the cap exists to bound, per tick per
mounted feed per user, to fix a display staleness.

The GET-budget positive control built its over-budget fixture from
MODEL_SALE_IDS_PER_QUERY. At 7-digit ids the serialized input is 8N + 9 chars
against MAX_GET_INPUT_LENGTH (2500), so break-even is N = 312 -- lowering the cap
below that turns the control's true into a false and reds the test for a reason
unrelated to the property under test. Now a fixed literal at 400 (3209 chars).

Comments and a test fixture only; no behaviour change.

Test Files 4 passed (4) / Tests 40 passed (40). Red arm re-derived against the
shipped tree (merge-base ModelCardContext + HEAD's tests): 12 failed | 10 passed
(22), headline "expected 1 to be 12". typecheck 0 errors; eslint --no-cache 0
problems on all three files; prettier clean against a negative control.

* docs(models): retract the cap-guard coverage claim in the two agent-facing docs

Round 2 found the retracted claim surviving verbatim in the two docs this PR
adds it to, while the guard's own docstring had already been corrected. Measured:
the string is absent at origin/main and present twice at the PR head, so this PR
introduced both copies.

Both now state the guard's real scope (server side only; it cannot see the call
site) and name the file that pins the other half, plus the fact that file is not
in test:lint-rules -- so a test:lint-rules run alone does not cover the seam.

no-lint-rules-script-drift: 11 passed (it pins names and counts, not these
parentheticals, so it was always green either way).

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:34:25 -05:00
briant 997eaaa15e 5.1.112 v5.1.112 2026-09-18 16:31:09 -06:00
briant de8cf569b6 Merge branch 'main' of https://github.com/civitai/civitai 2026-09-18 16:27:48 -06:00
Zachary Lowden e16409074c feat(app-blocks): measure and answer every postMessage bridge drop (#4946)
* feat(app-blocks): measure and answer every postMessage bridge drop

The host<->block bridge could discard a message five distinct ways, all
producing the same observable -- nothing -- and none of them measurable.
civitai_app_block_renders_total cannot see any of it: it fires once per host
mount, so every failure after BLOCK_READY is structurally invisible to it. A
custom-generators gallery read was dead for a full 15-day retention window
while 100% of render series across all 11 rendering apps read result=ok.

Both halves land in the shared dispatcher, so one change covers all 46 message
types rather than 46 per-handler edits:

- civitai_app_block_bridge_messages_total{app_block_id,type,host,outcome},
  outcome in {handled,no_handler,rate_limited,deduped,no_token}, emitted via a
  coalescing browser beacon to a new /api/track/block-message. Coalescing, not
  one POST per message: the bridge's own inbound limit is 30 msg/sec/host and a
  polling generator app is the common case; counts aggregate losslessly, so the
  series is identical to a per-message beacon's. Both client-supplied labels are
  clamped to code-owned sets on BOTH sides -- server-side it is the prom
  cardinality bound, client-side it bounds the buffer and the POST body.

- An error reply instead of a silent return. No handler for a REQUEST-style type
  now gets the protocol's own error variant for that type, so a block fails in
  milliseconds rather than hanging to its SDK timeout class (30s / 120s / 600s).
  The reply shape is per-family and cannot be uniform -- the workflow family
  needs a failureSnapshot or the SDK validator drops it -- and that resolution
  lives in one place. GET_WILDCARD_PACK and REQUEST_TOKEN are exempt with the
  reason recorded: their failure replies would be dropped by the SDK validator,
  so they are counted but not answered.

The card named 6 token-falsy sites; enumerating the class found 31. Nineteen now
route through the shared nack, eleven that already replied in a bespoke shape
gained reportNoToken (otherwise the no_token series would cover 18 of 30 while
its help text claimed all of them, missing exactly the money-adjacent paths),
and one counts only. noSilentTokenDrop.test.ts is the ratchet: every !token
branch in either host must answer, or be counted under a type the exemption
ledger names.

Red before, green after: the two host suites report 17 failed / 3 passed against
a clean origin/main worktree carrying only the new leaf modules, 20 passed at
HEAD. Three mutations named in review as surviving -- default sink to no-op,
nack dropping its report, host/app label swap -- are each confirmed caught.

* fix(app-blocks): close the delta-audit findings on the bridge telemetry

A round-2 audit over the round-1 review fixes found nine items. Every one that
was a real defect is closed here; the two that were only claims are corrected
rather than quietly dropped.

- A SPARSE-ARRAY HOLE landed in PageBlockHost's CREATE_POST_FROM_APP dependency
  list. Harmless at runtime (React compares by index and undefined === undefined)
  but a corrupted list in a 31-effect file, and structurally invisible to every
  green signal we had: no-sparse-arrays is not in this repo's eslint config, TS
  allows holes, and prettier preserves them. Found by running the rule explicitly.

- noSilentTokenDrop's "wider net" check could NOT see the regression the file
  exists for. It tested for a response anywhere in the enclosing onMessage chunk,
  and every handler's SUCCESS path contains a send(), so a planted 32nd handler
  with a bare `if (!token) return;` scored clean and moved no ledger number. The
  walk is now a real paren/brace match over each `!token` guard's OWN consequent,
  handler ranges are bounded by the call's parens instead of bleeding to EOF, and
  the unbraced one-liner is forbidden outright. That mutant now fails three
  assertions, including the one whose title names it.

- BRIDGE_MESSAGE_COUNT_MAX's derivation was wrong for three of the five outcomes.
  no_handler and deduped are reported ABOVE the inbound rate limiter, deliberately,
  and rate_limited only exists past it -- so "30/sec x 10s = 300, nothing real can
  reach it" was true only of handled and no_token, and a backgrounded tab's flush
  timer is throttled besides. Raised 2,000 -> 100,000 with an honest derivation:
  it is a sanity ceiling that keeps a flood VISIBLE rather than exact, not a rate
  control.

- The route comment claimed COUNT_MAX "caps what one request can add to a single
  series". It does not -- nothing enforces row uniqueness. Corrected to say what
  the route does enforce (cardinality, the prom-heap axis) and what it cannot.

- IframeHost counted a credential-less REQUEST_TOKEN only when a requestId was
  present, while PageBlockHost counted unconditionally -- and three comments plus
  a test title asserted they were in step. A requestId-less REQUEST_TOKEN is an
  explicitly documented protocol shape, and on it the count is the only observable,
  so IframeHost reported nothing. Both hosts now use reportNoToken unconditionally
  and the parity is asserted for BOTH hosts rather than described.

- The visibilitychange flush was untested: deleting the listener left the beacon
  suite fully green while the module's "no loss on navigation" claim and the whole
  flush-window argument rest on it, and a mobile tab switch fires only that event.
  Added, with the visible-state negative arm.

- The route test's count boundary was the literal 10_001, stale against the new
  cap and pinned to nothing; it now reads the constant.

- appBlockId was the last field reaching the buffer unclamped and is the only one
  that could still fail the schema on LENGTH and reject a whole batch.

Verified: typecheck 0 errors; eslint 0 errors; prettier clean; no sparse arrays in
any changed file; full unit project 1879 files / 42418 tests pass (the one failing
file, eventloop-watchdog.capture, fails at BASE too); AppBlocks component project
48 files / 595 tests pass; the two host suites still report 17 failed / 3 passed
against origin/main carrying only the new leaf modules.

* fix(app-blocks): the structural guard was reading a corrupted copy of the host

Round 3 of the audit ladder. Every finding was in the round-2 FIXES, which is
the point of re-auditing the delta each round.

THE BIG ONE. `noSilentTokenDrop`'s comment stripper was a regex,
`/(^|[^:])\/\/.*$/gm`. PageBlockHost contains `cleaned.includes('//')` — a `//`
inside a STRING — so the regex ate the rest of that line, deleting two `)` and a
`{` and leaving one `onMessage(` unmatched. The paren-bounding round 2 added to
stop handler ranges bleeding to EOF was therefore INOPERATIVE over 76% of the
file: one range ran 71,358 chars to EOF. Two consequences, both measured by the
auditor: a legitimate lifecycle `!token` guard added below that point failed with
a message about dropped requests (a booby trap for the next person), and three
real silent-drop shapes were caught only BY the corruption — repair the string and
they went green.

The stripper is now a state machine that blanks the CONTENTS of comments, strings
and template literals while preserving every byte offset, so no bracket inside a
string or comment can move a walk. A self-check asserts what the regex version
would have failed: brackets balance, the largest handler range is bounded, and no
range ends at EOF.

With the walk actually operating, four more holes were closable and are closed —
each confirmed by planting the mutant and watching it go red:

- a handler registered BY REFERENCE (hoisted into a useCallback) is invisible to
  any walk bounded by the registration's parens. The shape is now refused outright.
- a generic containing `=>` drove the angle counter negative, so the call's `(`
  was never found and the whole handler silently left the population.
- the exemption escape hatch matched an exempt type STRING in ANY handler, so a
  new handler could buy silence by copying the REQUEST_TOKEN guard and forgetting
  to change the argument — which also mislabels the telemetry. The exempt type must
  now BE the handler's own registered type.
- the REQUEST_TOKEN parity assertion read only INSIDE the consequent, so hoisting
  `if (requestId !== undefined)` one level out restored the round-2 defect with the
  test still green. It now also requires the `!token` guard to be the handler's
  first `if`, and selects the guard by the handler's REGISTERED TYPE rather than by
  the string appearing somewhere in a consequent.

Also:
- the retracted "6.6x, unreachable by a real client" derivation survived in a THIRD
  file, 25 lines above an edit the same commit made. Corrected.
- `appBlockId`'s clamp claimed "only the length can fail the schema". zod runs
  `.trim()` before `.min(1)`, so a whitespace-only id is truthy here, trims to ''
  server-side, and 400s the whole batch — the exact loss the clamp exists to
  prevent, from the one direction a length check cannot see. `.trim()` first, with
  a test.
- two wording nits in the count-cap derivation that its own next paragraph
  contradicted.

The file's "what it does not claim" section now also names the silent-drop
spellings the `!token` population does NOT cover (`if (token) {…}` with no else,
an aliased token, `== null`, `!props.token`) rather than leaving a reader to
over-read the guard.

Verified: typecheck 0 errors; eslint 0 errors over all 17 changed files; prettier
clean; AppBlocks component project 48 files / 596 tests pass; the node-side
AppBlocks + track tests 45 files / 644 tests pass. Five mutants planted and all
five red: unbalanced paren in a string, handler by reference, generic with `=>`,
borrowed exempt type, hoisted requestId gate.

* fix(app-blocks): parse the hosts with the TypeScript AST, not by hand

Round 5 of the audit ladder, and the root-cause fix rather than a fifth patch.

Three revisions of `noSilentTokenDrop.test.ts` hand-rolled a parse of the two
host files, and an adversarial audit found a NEW defect in every one. Every
finding was a PARSING bug; not one was a logic bug:

  r1  it looked for a response anywhere in the enclosing handler — which every
      handler's success path supplies with its own send('<X>_RESULT', …);
  r2  it stripped comments with a regex, which ate a `//` inside a STRING
      literal, left an `onMessage(` unmatched, and produced a 71,358-char
      "handler range" running to EOF;
  r3  it scanned characters correctly but sliced the CONTENT out of the RAW
      file, so a comment inside a branch could satisfy the response check and a
      real silent drop passed — a capability the r2 version had and r3 lost.

The same revision also had five ways to fail CORRECT code: a comment between the
type argument and the handler read as "registered by reference"; the payload-shape
guard every sibling handler opens with tripped the REQUEST_TOKEN parity check; a
handler 21 comment-lines longer than today's largest tripped a size bound whose
failure message blamed the scanner; and two ordinary regex literals — one of which
sits two lines from this file's own subject matter — either unbalanced the
brackets or silently blanked a line of real code.

All of that is a property of hand-parsing a language. `typescript` is already a
dependency, so the walk now uses `ts.createSourceFile` and asks about AST shape:
comments, strings, template literals and regex literals are not expressions, so
none of them can supply a call, a type name, or a bracket. The size bound and the
bracket-balance self-check are deleted outright — they existed only to detect the
hand-scanner corrupting its input.

With a real parser, four checks became expressible that were not before, and each
is the property that was actually meant rather than a proxy for it:

  - the registered type is read from the string-literal ARGUMENT NODE, so a
    comment can no longer hand a handler another type's NACK exemption;
  - "unconditional" is an ancestor walk, so `&&`, a ternary and a nested `if` are
    all caught — where counting preceding `if (`s saw none of them, and fired on
    correct code;
  - a call inside a NESTED FUNCTION does not count as executed by the branch,
    which closes the alias shape (`const count = () => reportNoToken(…)` then
    `if (…) count()`) that survived every other spelling;
  - the same bar now applies to the response check, whose name already claimed it.

Battery: 13 mutations, ALL 13 red, including every shape the audit named as
surviving. Four correct-code controls, all green; the fifth (a genuinely new
32nd handler) fails only the ledger count, which is that assertion working.
typecheck 0 errors, eslint clean, prettier clean, node-side AppBlocks + track
tests 45 files / 644 tests pass.

* revert(app-blocks): drop the structural no-silent-drop guard

It failed adversarial review five rounds running, in BOTH directions, and the
last round found more false positives than true ones. Shipping it would hand the
next contributor a trap, so it goes.

WHAT IT WAS. `noSilentTokenDrop.test.ts` tried to assert a CLASS guarantee the
behavioural suites cannot: that the thirty-second handler, written by copying the
thirty-first, cannot silently drop a credential-less request. Worth wanting — that
defect is invisible by construction, and it is the whole subject of this PR.

WHY IT GOES ANYWAY. Four revisions, four audits, a new defect every time, and
never a logic bug — every one was a defect in reading the source:

  r2  it looked for a response anywhere in the enclosing handler, which every
      handler's success path supplies with its own send('<X>_RESULT', …);
  r3  a comment-stripping regex ate a `//` inside a STRING literal, leaving an
      `onMessage(` unmatched and a 71,358-char "handler range" running to EOF;
  r5  the character scanner matched brackets on the blanked copy but read CONTENT
      from the raw file, so a comment in a branch satisfied the response check —
      a capability the previous revision had and this one lost;
  r7  rewritten on the TypeScript AST, it still missed four shapes, the sharpest
      being an early `return` placed BEFORE the responder rather than around it.
      `isUnconditionalWithin` walks ancestors, so it sees syntactic dominance and
      not reachability. That is the parity test's own stated defect re-spelled,
      and the idiom it needs already sits two lines above the guard in production.

The direction that decides it is the other one. Three ORDINARY, CORRECT
refactorings turned it red, each with a message naming the wrong cause:

  - `else if (!token)` instead of two sequential `if`s — semantically identical —
    reported as "nested inside another conditional";
  - a local helper arrow inside a handler reported the same way, and moved two
    ledger numbers as well;
  - a `useCallback`-wrapped inline handler — the idiomatic shape for a handler
    registered in a `useEffect` with a dep array, which is how all 44 of these are
    registered — reported as "registered by reference".

A guard that reads as coverage while providing none is worse than none, because
it stops anyone looking. A guard that fails correct code with a misleading message
is worse still: the next person's options are to contort the code or to delete the
test, and whichever they pick, this file taught them the wrong thing.

WHAT IS STILL COVERED, AND WHAT IS NOT.
Covered: `PageBlockHostNoTokenNack.browser.test.tsx` drives 12 message types on
the real host with a null token and asserts the per-family reply shape; both hosts'
REQUEST_TOKEN branch is exercised; `IframeHostUnhandledNack.browser.test.tsx`
covers the unhandled-type NACK; the seam tests pin the rows reaching the real
beacon buffer. Those were red at base — 17 failed / 3 passed — and are the tests
the task asked for.
NOT covered: the class. A thirty-second handler that drops a credential-less
request silently will not be caught by anything in this repo. That is a real,
named gap, and it is a smaller cost than a guard nobody can trust in either
direction. The comment in `IframeHost` that pointed at this file now says the two
hosts stay in step by hand.

* docs(app-blocks): stop pointing at the guard that was just removed

The REQUEST_TOKEN branch's comment cited `noSilentTokenDrop.test.ts` as the thing
asserting both hosts count unconditionally. That file is gone, so the citation was
a dead pointer to a guarantee nothing provides any more — the exact shape of rot
this PR's own doc-hygiene argument is about.

It now says what is true: the two hosts stay in step BY HAND, and the reason the
structural guard is not there is in the PR description.
2026-09-18 17:26:55 -05:00
briant f13756080f 5.1.111 v5.1.111 2026-09-18 16:26:50 -06:00
briant d273a67a09 fix meta description on user profile page 2026-09-18 16:24:06 -06:00
Zachary Lowden 720ed3e087 fix(app-blocks): make /api/v1/blocks/me and blocks.getMyViewer agree on authorization (#4950)
* fix(app-blocks): make /api/v1/blocks/me and blocks.getMyViewer agree on authorization

Two front doors to one capability disagreed about who may read viewer identity,
and the disagreement was masked by the Flipt audience rather than absent.

GET /api/v1/blocks/me carried a hardcoded isModerator -> 403 ("Phase 2: App
Blocks is moderator-only until GA"), no App-Blocks flag gate and no rate
limiter. Its tRPC twin blocks.getMyViewer had the flag gate and the rate
limiter, no moderator literal, and a docblock claiming it mirrored me.ts
EXACTLY. The live app-blocks-enabled audience is mostly moderators, for whom the
literal refused nobody the flag would have admitted -- but it also holds
hand-allowlisted non-moderators, and for every one of them the REST door 403'd
while the bridge returned 200. Widening the audience makes that the general
case.

The decision (operator, 2026-09-18) is that Flipt is the gate; a code-level
availability:['mod'] is documented as a Flipt-DOWN fallback only. So:

- Drop the moderator literal from me.ts, and the isModerator column from its
  select.
- Move assertAppBlocksEnabledForTokenUser out of blocks.router.ts into
  src/server/services/blocks/block-token-access.service.ts so both doors run ONE
  implementation. A Next API route cannot import the tRPC router, and a second
  copy of the predicate is how the two came to disagree.
- Give me.ts that gate plus checkBlockCatalogRateLimit, same bucket, same
  position (before the primary read) as getMyViewer.
- Replace the false "mirrors EXACTLY" docblock with explicit SHARED and
  NOT-SHARED lists, where SHARED is exactly what the parity test exercises and
  NOT-SHARED names the three places the doors genuinely differ -- including two
  pre-existing bridge-side divergences this change records rather than fixes.
- Repoint scripts/compiled-branch-watchlist.mjs's module: the watchlisted
  fail-closed branch block-token-subject-refusal moved with the function.

me.ts renders both kill-switch refusals with its own literal and does NOT echo
the gate's message: rest-error-envelope-ledger.test.ts blocks a REST route from
serialising a caught error's .message, and the unhydratable-subject message is a
compiled-branch anchor that must stay unique app-wide. Detection is duck-typed
on .code rather than instanceof, matching the sibling routes, because
instanceof fails across a duplicated @trpc/server instance in an API bundle.

Tests: blocks.router.me-parity.test.ts drives BOTH doors with one subject and
compares a normalised verdict. Measured in a CLEAN checkout of the base commit
with the HEAD test files dropped in -- at 0340f692bf, then re-measured at
d7038c5aa8 after the base moved, 7 failed / 3 passed both times: 7 of 10 FAIL, in
BOTH directions -- REST too strict for a non-moderator in the audience (allowed,
banned, muted), REST too permissive for a moderator outside it and for an
over-limit instance, one door not calling the gate at all, and a non-tRPC error
not being rethrown. The other 3 are labelled at their own assertions as invariant
or mutation guards and are NOT counted as regression coverage -- including one
that is green at base only because both doors answer 403 there by coincidence.

Mutation-tested rather than assumed. Adversarial review found three mutants that
SURVIVED an earlier revision of the suite and they are now killed by cases added
for them: `toHaveBeenCalledWith` is satisfied by EITHER door when both doors call
one mock, so keying a limiter on `jti` or calling the gate with a wrong subject
went unseen (both assertions now compare `mock.calls`); hardcoding the refusal
status instead of deriving it went unseen because every refusal the gate can
currently produce is UNAUTHORIZED (case H); and widening the duck-typed catch to
`if (true)` went unseen, which turned a plain Error into a 500 wearing a policy
refusal's body (case I).

Does NOT widen the Flipt segment; that is a separate change this one unblocks.

* test(app-blocks): pin the gate/limiter ORDER, label the third invariant guard, ledger the exported kill-switch

Audit round 1 on #4950 cleared the payload and found three gaps in the GUARDS
around it. All three are test/doc-side; no production behaviour changes here.

F1 (the one that matters) — A SURVIVING MUTANT, on exactly the defect this PR
exists to fix. Three docblocks claim the parity test pins the kill-switch and the
rate limiter in the same ORDER on both doors ("same position", "same placement",
asserted to be "exactly what the parity test EXERCISES"). It did not. Measured:
hoisting the limiter block above the kill-switch try/catch in me.ts left the
parity file 10/10 and 16 sibling suites 417/417 green. 13 of 14 audit mutants
died; this one lived.

The reason is that every existing case arms at most ONE of the two refusals, and
with one armed the order is unobservable -- whichever gate is armed answers,
wherever it sits. Position relative to the PRIMARY READ was already pinned (B and
D assert the db was never touched, which killed a gate-after-db mutant); position
relative to EACH OTHER was not.

Fixed by adding the case, not by narrowing the words: case J arms BOTH (flag
false AND limiter denied) and asserts the two doors return the same verdict --
401 "Apps are not enabled", the kill-switch, because it runs first -- plus the
positive half, that NEITHER door reaches the limiter at all. Re-running the exact
mutation now fails exactly case J, for its own reason (verdict inequality):
before 26/26 green, after 1 failed / 25 passed.

A docblock claiming coverage the test lacks is this PR's whole thesis. It would
have been one more instance of it, in the file arguing against it.

F2 — the parity header said its three green-at-base cases are "each labelled at
its own assertion". E was labelled in its title and H in its body; G was not, so
a reader landing on G from a failure got no in-place signal it is an invariant
guard rather than regression coverage. Now labelled in both.

F3 — `assertAppBlocksEnabledForTokenUser` became exported by this PR, and its
contract (the id MUST be the self-bound token subject) stopped being checkable by
reading one file. Nothing enumerated its callers: the bridge reachability guard
covers a different function. Adds a call-site ledger that pins the consumer SET
and per-consumer call counts, failing when the set grows, shrinks, or a new call
site appears inside an existing consumer.

Resolution is by IMPORT, never by name: apps.router.ts declares its own
same-named `(userId, op)` variant that is a deliberate documented divergence, and
a name-matching ledger would have counted it and invited someone to "reconcile"
two functions that are separate on purpose. That module is asserted as an
explicit negative control, alongside a positive control so a matching set cannot
be a wired-to-nothing zero.

The ledger states its own limit plainly: it does NOT verify self-binding. Proving
an argument descends from parseSubjectUserId textually is not something a regex
does honestly, and a structural check type-checks past a wrong argument anyway.
What it buys is that a new caller cannot land silently -- it lands in a diff next
to the contract. Watched to fail in all three directions before being trusted.
2026-09-18 17:23:11 -05:00
Zachary Lowden ad3134ccc6 fix(bot-detection): stop asset-staging's top rung outscoring its strongest rung (#4954)
* fix(bot-detection): stop asset-staging's top rung outscoring its strongest rung

The `asset-staging` volume ramp ran (zeroAt 1, oneAt 3), scoring a staged
count of 1 at 0, 2 at 0.5 and 3-or-more at 1.0. That ordering is backwards
against what the heuristic is for.

A rising ramp puts its top rung on the largest counts, and on this predicate
the largest counts are the wrong population: a coordinated upload clusters at
exactly TWO assets, an avatar and a header, which is what a profile needs and
no more, while a legitimate profile setup -- a business, or a creator bringing
in a kit -- runs to THREE OR MORE. So the rung the heuristic weighted highest
was the rung carrying disproportionately many legitimate accounts, while the
shape it exists to find sat at half of what it selected.

Move STAGED_ONE_AT from 3 to 2 so the ramp saturates at the firing point:
count 1 scores 0, count 2 and above score 1. A plateau, not a suppression --
the 3+ arm still scores its maximum and still reaches a moderator on its own.
It is the worse of the two arms, not an empty one, so the requirement is that
it stop OUTSCORING a pair, never that it stop scoring. rampScore is monotone
non-decreasing by construction and throws on oneAt <= zeroAt, so a declining
shape is not expressible in the shared helper without a second ramp term; of
the shapes that are expressible, the plateau is one constant.

Costs, recorded in the code rather than left to be discovered:

- The volume half now has no gradient -- it is a step -- so the sub-score
  cannot express "more staged than that". The count itself is still disclosed
  verbatim to a moderator by explain().
- The two rungs are no longer distinguishable in any counter a run emits, so
  a future re-shape has to be graded against moderation outcomes rather than
  read off the shadow-phase counters.
- Two series move on deploy with no account behaving differently: a lone
  asset-staging blend goes 0.125 -> 0.25 (one confidence bucket for a
  lone-signal account), and asset-staging's sole_signal inflates because the
  dominance test's runner-up tolerance scales with the leader's score.
  The reported population is unchanged at the shipped cut.

Tests: adds "THE ORDERING", which pins score(2) >= score(3) as a comparison
rather than as two literals, and also pins that the 3+ arm keeps scoring and
stays independently reportable -- so the ordering cannot be satisfied by
suppressing it. Watched red against the unmodified ramp
("expected 0.5 to be greater than or equal to 1") and green after.

Existing expectations that moved are the arithmetic consequence of the new
boundary. One case, "scores 0 for ONE staged upload and fires from two", is
deleted: its assertions had become a subset of the saturation case's, so no
mutant could separate them.

Comment changes in ramp.ts, run.ts, scoring.ts and the test files correct
statements this change falsified -- chiefly two that said the volume and burst
halves share boundaries, and one giving a degenerate step as the reason for
rampScore's throw, which the new adjacent-integer pair makes false.

MIN_REPORTED_CONFIDENCE, LONE_SIGNAL_CUT, the heuristic registry and the burst
boundaries are untouched.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(tests): qualify two false absolutes about which boundary pairs rampScore clamps

Review round 8 found one finding, in prose only; no assertion, fixture or
expected value changes, and the file's code is byte-identical (verified by
stripping comments with the TypeScript parser).

The asset-staging loop-vacuity note said `burst <= 1 === volume` holds for
"every boundary mutation, since rampScore clamps". It does not. rampScore
guards `!(oneAt > zeroAt)` and THROWS on a degenerate pair, so such a pair
produces no output in [0, 1] at all and the loop goes red rather than holding
-- measured, `BURST_ONE_AT -> 1` with its pin updated turns 83 of 398 cases
red on that error. The hypothesis the sentence rests on ("output stays in
[0, 1]") and both of its named counter-examples were already correct; only
the appositive was too wide.

That matters more than a nit because the same file retracts this exact class
by name two hundred lines earlier, where the same edit is described as
throwing and taking ~80 cases with it. The two paragraphs disagreed, and the
newer one was the wider.

Second instance of the same shape, same file: the ORDERING case said its
comparisons hold for "EVERY boundary pair with oneAt <= 2" -- a pair with
zeroAt >= oneAt satisfies that quantifier and throws.

Both errors ran in the safe direction: they overstated how vacuous the
assertions are, i.e. understated coverage, so neither gave false confidence
in a guard.

Also corrects a positional reference that the round-7 reorder invalidated --
`spread.burst` is no longer "at the foot" of its case, it is the penultimate
block, since the count-1 control was deliberately moved below it.

Suite 398/398, ESLint and Prettier clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 17:20:35 -05:00
Koen 0829f6ab0f fix(feed): count deep-offset requests as rejected, not unmapped (#4960)
Requests past the feed's offset limit have returned an error since #4898,
but the primary counter still filed them under `unmapped`, next to the
requests that fall back to Meilisearch. They now carry outcome `rejected`;
the reason label is unchanged.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 23:10:53 +01:00
Briant Diehl 0af959da62 Merge pull request #4956 from civitai/yue2-model-card
Enable YuE2 model-card generation
2026-09-18 16:08:54 -06:00
briant 8afaa0cfef fix(ingestion): log the real error and elapsed time when a scan submit gets no response
A submit that never gets a response is the one case where the error's identity is
the whole diagnosis, and it was the one thing the log did not carry:
JSON.stringify(new Error()) is `{}` because Error has no enumerable own properties,
so every no-response failure recorded `error: {}`. Pass it through safeError, which
the repo already uses for exactly this, and add the wall time across all attempts —
the attempt count alone cannot tell three 15s aborts from an instant rejection.

The test pins the serialization rather than the call: reverting to the raw error
fails with `expected '{}' not to be '{}'`.

The logging mock in the covering suite was hand-listed and silently dropped
safeError; spread the original instead, since that module is pure apart from
logToAxiom.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 15:37:34 -06:00
rmatif 403bfaed8d Enable YuE2 generation from its model card 2026-09-18 23:14:43 +02:00
Zachary Lowden dce428a492 feat(app-blocks): author-fee accrual ledger (slice 2a, dark) (#4944)
* feat(app-blocks): author-fee accrual ledger + daily settlement rail (slice 2a, dark)

The rail that pays an app author the per-generation fee slice 1 (#4922) learned
to compute. Adds the ledger, the daily settlement job and the clawback path.

NOTHING WRITES TO THE LEDGER YET. The viewer-charge path is deliberately not in
this PR — see "What is NOT here" below. So this moves no money: the table stays
empty, the settlement job no-ops on an empty scan, and the whole path is behind
app-blocks-author-fee-enabled, which is false.

TWO HOPS, MIRRORING THE MODEL LICENSING FEE. The licensing rail charges the
viewer at generation time and writes a per-resource fee row, then
deliver-creator-compensation mints to the creator daily. Same two hops and the
same externalTransactionId dedup discipline here, in a civitai-owned table —
because orchestration.resourceCompensations is keyed on a modelVersionId and
written by the orchestrator, and an app fee has no model version. That fork is
recorded rather than left for a reader to rediscover.

NO FRACTIONAL ACCRUAL, AND THAT REVERSES THE DESIGN IT INHERITED. The licensing
fee is fractional because it is priced per-image at 0.01 buzz and the viewer pays
the ceiling of the sum, so the creator's share genuinely has sub-buzz resolution.
This fee does not: max(flat, pct x base) is floored to whole Buzz before the
viewer is shown or charged it (D7 requires the viewer see the exact number before
the run, and Buzz cannot express a fraction). The author is credited exactly what
the viewer was debited — the platform is a conduit and takes no cut. The daily
batch therefore exists for ledger volume, not rounding.

Consequence, stated in three places because slice 3 must surface it: an author
who sets a 0 flat leg and a low percentage earns nothing on cheap generations,
forever. Same shape as the $0.00 spend bounty this arc replaced; the difference
is it is now the author's explicit choice and the platform default avoids it.

DECISIONS IMPLEMENTED
  D6  blue Buzz in, blue Buzz out — settlement groups by buzz type and never
      coerces. A collapsed bucket would convert non-withdrawable Buzz into
      withdrawable earnings and no total would change.
  D10 the percent leg prices off base only (no code change; recorded).
  Self-dealing is excluded at accrual, and counted rather than dropped.
  The app owner is snapshotted at WRITE time so an ownership transfer cannot
  retroactively move earnings already accrued.

CLAWBACK. The orchestrator refunds undelivered work after submit, so the fee has
to follow or an author earns on a generation the viewer got refunded. Not a new
policy — CalculateLicenseFees already weights every fee by delivered fraction.
Before settlement the accrual is voided in place; after it, a negative
carry-forward row nets against the next run. A bucket whose net goes non-positive
is HELD, not forgiven at zero, so the debt stays visible.

Mint happens BEFORE the status flip, deliberately: a crash between them settles
late rather than paying twice, and the deterministic dedup key is what makes the
retry safe.

MIGRATION IS MANUAL-APPLY, AND SHIPS BEFORE THE CODE (rule 8, the #4903
precedent). Committed for history only.

VERIFICATION
  pnpm typecheck                     OK - 0 type errors in 165s
  settlement suite                   17 passed
  mutation sweep                     6/6 killed, EACH by its own named test:
      self-dealing guard, D6 bucket key, dedup-key composition, non-positive
      hold, clawback sign, zero-fee guard
  positive control                   applied +1000 to the minted amount ->
      2 tests red, proving the harness executes the code under test

What is NOT here, and why: the viewer-charge path. The fee must be priced from
the whatIf base and added to BOTH reservations before submit — otherwise it
escapes the viewer's per-app consent budget entirely, which is the one real
safety hole this design found. That touches four submit paths on the hot billing
path and deserves its own focused audit rather than being folded in behind a new
table. It is the next PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): FK targets app_blocks, not AppBlock — and the entry_type CHECK is unreachable

Two corrections the dev-first apply caught, both in the migration only.

1. The app_block_id foreign key referenced "AppBlock", which does not exist.
   The Prisma model is AppBlock but it carries @@map("app_blocks"), so the
   physical table is app_blocks — confirmed against the existing
   block_spend_attribution_app_block_id_fkey, which points at app_blocks. Applied
   as written this would have failed outright on every database. Hand-writing the
   migration rather than generating it is what lost the mapped name.

2. The entry_type CHECK is SUBSUMED by the amount-sign CHECK and cannot fire.
   The sign check reads (entry_type='accrual' AND fee>=0) OR (entry_type='clawback'
   AND fee<=0), so any third value makes both disjuncts false and is rejected
   there first. Measured, not reasoned: an insert with entry_type='bogus' on dev
   came back rejected by ..._amount_sign_check, never by ..._entry_type_check.
   The constraint stays as an explicit statement of the allowed set, but it is now
   labelled as not-a-reachable-guard so nobody reads it as coverage. If the sign
   check is ever loosened this becomes live and needs its own negative control.

APPLIED AND VERIFIED — dev first, then prod, per rule 8.

  dev  cnpg-cluster-dev-1 / cnpg-database-dev
  prod cnpg-cluster-nvme0-5 / cnpg-database (primary re-derived live), with
       SET lock_timeout='5s' — the four FKs take a lock on User, a hot table, so
       failing fast beats queueing behind it

  Both databases: 18 columns, 4 foreign keys, 5 checks, 4 indexes, 0 rows, with a
  positive control (a bogus column name returns 0, so the query discriminates).
  block_spend_attribution unchanged at 602 rows.
  DDL replayed to both prod standbys (-4 and -7 report 18 columns).

  Constraint behaviour was exercised on dev, not merely confirmed present: a valid
  row inserts (positive control), and 7 negative controls are all rejected — 6 of
  them by their own named constraint. The seventh is finding 2 above.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): round 0 — two comments asserted seams that do not exist, and the new-table rationale was false

Round 0 (requirements & deletion) found five things wrong with what this PR
ASSERTS rather than with what it does. Four are fixed here; the fifth is a
deletion decision for the operator and is left open.

1. THE NEW-TABLE RATIONALE WAS FALSE, in both the migration and the Prisma
   docblock. It claimed block_spend_attribution is "IMMUTABLE by design". It is
   not — that model carries status, voidedReason, confirmedAt, voidedAt,
   paidOutAt and payoutId, the same accrued/settled/voided lifecycle plus a
   payout key. Those columns are merely dead today because the rail that wrote
   them was removed, which is a different claim. A reader who checked the schema
   would have found the lifecycle columns and concluded the reasoning was wrong.

   The requirement survives on a better reason, verified rather than substituted:
   recordSpendAttribution runs inside `void (async () => { … })()` so that a
   failed attribution write can never break a generation — droppable telemetry.
   An accrual is a money obligation and must be awaited. The seam, not the row
   shape, is what separates them.

2. A comment named `chargeBlockAuthorFee` "in the router" as the caller that
   performs the debit. That function has never existed anywhere in this
   repository; the name appeared only in that sentence.

3. A comment claimed "the charge path reads this same predicate before taking the
   money" about the self-dealing exclusion. There is no charge path and no shared
   predicate — slice 1 has no self-dealing check at all. Corrected, and turned
   into an explicit obligation on slice 2b: call this before the debit or extract
   it, because the exclusion is NOT enforced upstream today.

4. BlockAuthorFeeAccrualStatus and BlockAuthorFeeEntryType were exported and then
   never referenced, including inside their own file — every status was written as
   a bare literal, so a typo'd 'setled' would have compiled and matched nothing.
   Now bound to named constants used at all ten write and compare sites.

5. The settlement job's getJobDate/setLastRun cursor gated nothing.
   settleBlockAuthorFees scans `status: 'accrued'` with no date filter, so the
   cursor was read only to interpolate into a log line and then written back —
   two DB round-trips and a persisted KeyValue row no branch consulted. The real
   idempotency mechanism is the deterministic externalTransactionId. Removed; a
   cursor that reads like a run-once guard while guarding nothing is worse than
   none.

Also: the D<n> labels had no referent inside this repository — they index an
internal decision memo that is not here, so a bare "D6" was authority a reader
could not resolve. D1, D6 and D7 are now stated in full at the top of the
service, and the note records that D8 and D10 implement nothing in this file.

And a caveat that was missing entirely: a newly added cron is NOT picked up by a
deploy. Jobs are discovered through /api/internal/get-jobs and the external
scheduler needs an explicit refresh, so this job will not be dispatched until
someone performs that out-of-band step. Merged does not mean running.

VERIFICATION after the fixes
  pnpm typecheck   OK - 0 type errors
  settlement suite 17 passed
  mutation sweep   6/6 still killed, each by its own named test — re-run after
                   the literal-to-constant refactor, since that touched all ten
                   status/entry comparison sites

LEFT OPEN, deliberately — operator decisions, not mine:
  * clawbackBlockAuthorFee has ZERO production callers, and its negative
    carry-forward arm is unreachable until something has settled (two PRs away).
  * The base rate: bulk-payout-block-attributions.ts is the same machine —
    registered daily job, idempotent mint, clawback carry-forward, net<=0 hold —
    built 2026-05-31 and still unwired. mintPayoutForOwner has no production
    caller to this day.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(app-blocks): retire the clawback from slice 2a — it had zero callers and an unreachable arm

Round 0's deletion candidate, taken. The clawback reverses a charge this PR
deliberately does not make, so it cannot be needed yet:

  * clawbackBlockAuthorFee had ZERO production callers — only its own tests.
  * Its negative carry-forward arm is reachable only once a row has status
    'settled'. Nothing has settled and nothing can until the charge path lands
    and the job runs for a day — at least two PRs away.

The base rate is what made this decisive rather than tidy.
bulk-payout-block-attributions.ts is the same machine — registered daily job,
idempotent mint, clawback carry-forward, net<=0 hold — built 2026-05-31 and
still unwired; mintPayoutForOwner has no production caller to this day. The way
that rail rotted is that it shipped ahead of its consumer. Slice 2b brings the
clawback back together with the refund path that drives it.

REMOVED
  clawbackBlockAuthorFee, ClawbackReason, ClawbackBlockAuthorFeeResult
  the entry_type column, its composite unique, and BlockAuthorFeeEntryType
  the amount-SIGN check (there are no negative rows now)
  the entry_type check — already measured unreachable, shadowed by the sign check
  the 'clawed_back' status
  the non-positive bucket hold, bucketsSkippedNonPositive, and its test
  5 clawback tests

REPLACED, so the invariant lives where it can actually be violated
  CHECK (fee_buzz > 0). The settlement job's old "a bucket can never sum to <= 0"
  branch was unreachable the moment negative rows went away, and an unreachable
  guard reads as coverage while providing none. The rule is now enforced at the
  write (accrueBlockAuthorFee refuses fee <= 0) and in the schema, not in a dead
  branch.
  Unique key is now workflow_id alone.

VERIFICATION
  pnpm typecheck   OK - 0 type errors
                   (one real error caught first: the job still logged the removed
                   bucketsSkippedNonPositive field)
  settlement suite 11 passed (was 17; 6 removed with the clawback)
  mutation sweep   4/4 killed, each by its own named test — self-dealing, D6
                   bucket key, dedup-key composition, zero-fee guard
  positive control +1000 on the minted amount -> 2 tests red, so the harness
                   genuinely executes the code

SCHEMA RE-APPLIED — dev then prod, table dropped and recreated (it was empty and
nothing referenced it). The drop was GUARDED: a DO block re-counts rows and
inbound constraints at the moment of the drop and raises rather than destroying
anything, so a row that landed in between aborts the transaction. Guard printed
"0 rows, 0 inbound references" on both.

  dev  cnpg-cluster-dev-1        17 cols, 4 checks, 0 rows
  prod cnpg-cluster-nvme0-5      17 cols, 4 checks, 0 rows  (lock_timeout 5s)
       standbys -4 and -7 converged to the same after WAL replay; -7 lagged ~30s
       and was polled to convergence rather than assumed
  block_spend_attribution unchanged

  Constraints exercised on dev, not merely counted: a valid row inserts, and 6
  negative controls are each rejected BY THEIR OWN named constraint — no
  shadowing now that the redundant entry_type check is gone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): round 1 — the settlement rail could lose money one way and pay twice the other

Round 1 returned "needs rework" with four blockers. All four fixed. CI was RED at
the previous head and I had not checked it after pushing; that is what surfaced
three of these.

1. 🔴 THE MINT RESULT WAS DISCARDED, AND ROWS FLIPPED REGARDLESS.
   createBuzzTransactionMany does NOT throw on a per-transaction failure — its own
   comment says an insufficientFunds or otherwise-rejected result "is dropped from
   BOTH arrays: the money did NOT move and it is otherwise invisible". It also
   filters out transactions failing `fromAccountId !== toAccountId && amount > 0`
   before calling the service. The old code awaited it, ignored the return, and
   flipped every row to settled: the author was never paid while the ledger
   asserted they were, and status='accrued' was the only handle that could have
   found it afterwards. Now reconciled by COUNT (successes come back as opaque
   ids, so the count is the only thing that reconciles); a conflict counts as
   settled because the money already moved under that key. If the count does not
   account for every bucket, NOTHING is flipped — we cannot tell which dropped, so
   the rows stay accrued and retry. The precedent is challenge-funding.ts, which
   carries the same reconciliation and the same comment.

2. 🔴 THE DEDUP KEY NAMED A DAY BUT THE SCAN WAS UNBOUNDED, so it was wrong in
   BOTH directions, and the module comment confidently asserted the opposite.
     (a) SILENT LOSS — a second run on the same day swept up rows accrued since
         the first, minted them under the SAME key, got a benign-looking conflict,
         and flipped them settled. Money gone. Compounded by (1), which made it
         invisible.
     (b) DOUBLE PAY — if the flip failed after a good mint, rows stayed accrued
         until the next run 24h later, under a DIFFERENT date key, and were minted
         again. The comment claimed "the next run re-derives the same key"; on a
         daily cron it never does.
   Fixed by bounding the scan at the day boundary: a run for day D settles only
   rows accrued strictly before the start of D. Every row now belongs to exactly
   one settlement day, derivable from the row, so (a) cannot sweep fresh rows and
   (b) re-derives the same key and conflicts instead of paying twice.

   🔴 This is also why deleting getJobDate/setLastRun last round was not the whole
   story. It gated nothing AS WRITTEN — that part was right — but it was standing
   in for a run-once guard, and removing it without replacing the mechanism left
   (a) exposed. The boundary is the replacement, and it is stronger: it bounds the
   ROWS rather than the invocations, so it holds under the concurrent runs
   createJob's lock expiry can produce.

3. 🔴 CI RED — three checks, all mine:
   * block-spend-attribution-status-default — and this one was emitting a FALSE
     claim about a table this PR does not touch. The guard filtered migration
     files with a file-level `sql.includes(TABLE)`, which reads PROSE: this
     migration's comment header explains why it is a SEPARATE table from
     block_spend_attribution, which passed the filter; the `"status" TEXT …
     DEFAULT` regex then matched THIS table's own column and won on last-wins
     sort order. It reported `provisions DEFAULT 'accrued' … while the schema says
     @default("tracked")` about a column nobody had changed. That is worse than a
     false red: the guard exists because a default the payout read does not select
     makes rows invisible to payout with no error (#4036), so anyone "fixing" it
     by following its message ships that defect. Fixed in the GUARD — comments
     stripped, file split into statements, only statements naming the table
     scanned — not by rewording this migration, which would leave the next table
     to trip it.
   * app-access.call-site-ledger — the guard fails on GROWTH because it forces a
     collaborator decision. Registered: the author fee accrues to the app OWNER
     only, deliberately not widened to ACCEPTED collaborators. That is the
     ledger's existing D4 applied to a new earnings surface, not a new decision —
     earnings are appOwnerUserId-keyed precisely so an ex-owner keeps what they
     accrued before transferring an app away, and widening the WRITE would make
     that inexpressible. Resolving the owner at settlement instead would
     retroactively re-route earnings on every transfer.
   * ESLint + Prettier — both new files were unformatted. Formatted.
   Plus no-direct-shared-module-mock: the test now uses the canonical db/logging
   mocks via the repo's own codemod.

4. 🟡 buzz_type had no CHECK — the one money-critical column without one, while
   status and governing_leg both had theirs. BuzzAccountType also contains BANK
   types (creatorProgramBank, cashPending, cashSettled, club), and settlement cast
   the column to it unchecked, so a junk or bank value would surface only as a
   silently-dropped transaction. Now CHECK IN ('blue','green','yellow','red'),
   verified on dev: 'creatorProgramBank' rejected by that constraint by name,
   'blue' accepted as a positive control.

Also fixed: the settlement scan read the REPLICA while the flip wrote the primary
(replica lag would re-bucket an already-settled row); buzzMinted was incremented
even when updateMany matched 0 rows, so the job logged an affirmative "minted" for
money it had not moved; and the settlement key was built from two independent
spellings, only one of which was pinned.

TESTS — the absences round 1 named were structural, not oversight: the fake
resolved createBuzzTransactionMany to `undefined`, which can express neither a
conflict nor a drop. It now returns `{ transactions, conflicts }` like the real
one, and five tests were added: mint-did-not-reconcile flips nothing, a conflict
counts as settled, the day boundary is midnight of the settled day, a concurrent
flip claims no buzz, and one key serves both the mint and the row stamp.

VERIFICATION
  pnpm typecheck        OK - 0 type errors
  the four red suites   50 passed (4 files)
  prettier --check      clean (positive control: the earlier --write listed all
                        five paths by name, so they do resolve)
  mutation sweep        6 real mutants, 6 KILLED, each by its own named test:
                        discarded mint result, missing day boundary, replica read,
                        unconditional buzz count, duplicated key spelling
  positive control      +1000 on the minted amount -> 2 tests red
  1 SURVIVOR, and it is an EQUIVALENT MUTANT, not a gap: dateStr off `date`
  vs off `boundary` is the same string for every input (toISOString is always
  UTC and boundary is midnight of that same UTC day; checked across day edges
  and a year boundary). Recorded in-code so nobody writes a test asserting a
  difference that cannot exist.

SCHEMA re-applied dev then prod for the buzz_type CHECK, same guarded drop
(re-counts rows and inbound references at the moment of the drop, raises rather
than destroying). All three prod instances: 17 cols, 5 checks, 0 rows. Standby -4
lagged ~30s and was polled to convergence rather than assumed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): round 2 — the settlement key named the RUN day, so a deferred retry paid twice

Round 2 found that round 1's flagship fix did not do what it said, and that its
fail-closed branch made things worse. Both confirmed against the code before
acting; both were mine.

🔴 F1 — THE KEY WAS DERIVED FROM THE INVOCATION, NOT FROM THE ROW.
`dateStr` came from `args.date` — the day the job ran — while the scan admitted
every accrued row below the boundary, i.e. all of history, bucketed by
(owner, buzzType) with no accrual day. So a row whose flip failed on 09-18 was
re-scanned on 09-19 and minted under `…-2026-09-19-…`: a NEW key, no conflict,
owner paid twice. That is exactly the failure mode the module comment claimed
the day boundary had fixed — asserted three times, false every time. The
sentence "an unflipped row still belongs to its original day, so its key is
unchanged" was the worst of them: the day never came from a row.

🔴 F2 — THE FAIL-CLOSED BRANCH FED F1. One dropped bucket meant NOTHING in the
batch was flipped, including buckets whose money had already moved — and those
then sat `accrued` until the next day, where F1 re-minted them. Round 1
introduced this while fixing an under-payment, and its comment claimed the trade
was "money owed, which is recoverable". It was money PAID TWICE, which is not.

🟡 F3 — a same-day second run still lost money via `take` truncation: run 1 mints
a partial bucket and flips, run 2 picks up the remainder, builds the same key,
gets a conflict, and flips those rows without ever paying for them. A conflict
says that KEY minted; it does not say those ROWS were in it.

THE REDESIGN — one complete accrual day at a time, keyed on the row.
  * The loop settles the OLDEST unsettled accrual day, scanning exactly
    [dayStart, dayEnd) rather than everything below a boundary.
  * The bucket carries `accrualDay`, and `keyForBucket` builds the key from it.
    Every component now comes from the rows; nothing comes from the clock. A
    retry tomorrow, next week, or after a month with the flag off re-derives the
    SAME key and conflicts. F1 closed.
  * A day that exceeds `limit` is SKIPPED WHOLE and reported (`daysTruncated`),
    never cut. `take: limit + 1` makes the overflow detectable instead of silent.
    A partial bucket is what F3 lost money to, so the rule is that a bucket is
    always settled whole. F3 closed.
  * ONE BUCKET PER MINT CALL, so a drop is attributable. createBuzzTransactionMany
    reports only counts and opaque ids, which is why the batched version could not
    say which bucket failed and answered by flipping nothing. Per-bucket makes it
    answerable: this bucket moved, or it did not — and its peers are unaffected.
    F2 closed.

🟢 F6 — the "EQUIVALENT MUTANT" comment was FALSE as written, and I had checked it
with fixtures that could not see the counter-example. Date.UTC maps years 0-99 to
1900+y, so year 0026 gives `0026-…` from one spelling and `1926-…` from the other.
Unreachable in production, but the comment forbade writing the test that would
have found it. Gone with the rewrite.

🟡 F4 — the job's prose contradicted the service: it still said the scan had "no
date filter" (the recorded rationale for deleting the cursor) and "since the last
run". Both corrected, and the cursor's real justification stated: idempotency
lives in the ROWS, not the invocation, which is what holds when createJob's lock
expires and two runs overlap.

🟢 F5 — the status-default guard stripped only `--` comments while Prisma-generated
migrations in this repo open with `/* Warnings */` headers, so the prose was wider
than the code. Both comment forms are stripped now.

VERIFICATION
  pnpm typecheck    OK - 0 type errors
  affected suites   52 passed (4 files)
  prettier          clean
  mutation sweep    7 mutants, 7 KILLED, each by its own named test: run-day key,
                    truncated oversized day, flip-on-drop, un-day-scoped scan, D6
                    bucket collapse, unconditional buzz count
  positive control  +1000 on the minted amount -> 2 tests red

No SURVIVORS this round. The previous round reported one and explained it away as
equivalent; that explanation was wrong (F6), which is a reason to distrust a
survivor-with-a-story rather than to be reassured by one.

Schema unchanged — no migration in this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): round 3 — the day loop could never advance past a day it could not finish

Round 3's verdict was "merge after fixing 🔴-1". The money path held this time —
eleven of thirteen claims landed clean and no double-payment or silent-loss path
survived — but the rewrite introduced a LIVENESS defect, and my own mutation
sweep could not have caught it.

🔴 A DAY THE LOOP COULD NOT FINISH BLOCKED EVERY LATER DAY.
The loop re-derived "the oldest unsettled day" on every iteration with nothing to
advance past one that did not complete. Two arms:
  * an OVERSIZED day hit `break` and stayed the oldest forever, so every later day
    was blocked permanently — and since the job passes no `limit`, the only
    recovery was a code change and a deploy;
  * a persistently REJECTED bucket left its rows `accrued`, so the next iteration
    re-selected the same day and re-minted it, burning all 30 iterations on one
    stuck owner while every other author stopped being paid.
Neither risks money — the row-derived key still makes a retry conflict — but both
halt settlement for everyone else behind a single log line. The previous round's
comment even stated the rule for the second arm ("the day is still the oldest, so
the next iteration would select it again and spin") sixty lines above the code
that did exactly that.

FIXED with a monotonic per-run cursor: the day selection asks for the oldest day
AT OR AFTER `cursorFrom`, and the cursor advances to `dayEnd` BEFORE any early
exit, so every path leaves its day behind. A stuck day now costs one iteration and
is retried on the next run instead of wedging the queue. The oversized and
emptied-day arms `continue` rather than `break`, which is only safe because the
cursor makes a spin impossible.

🟡 THE MINT IS NOW WRAPPED. The buzz client THROWS on any non-2xx and its retry
allowlist covers only connection-level errors, so a 5xx throws on the first
response. Unwrapped, bucket 1 of N aborted buckets 2..N, the day loop and the
completion log — and splitting one batched call into N per-bucket calls multiplied
that exposure by N, so this was a hazard the previous round's own fix created. A
throw is now handled exactly like a drop: rows stay `accrued`, the same key is
re-derived next run, peers still settle.

🟢 Two claims corrected rather than reworded: the module header still described a
`Math.floor` the previous commit deleted (the only remaining `Math.floor` in the
file was inside the sentence about it), and "a conflict is the same payment" was
stated unconditionally when it holds only while no row can JOIN an
already-minted (day, owner, currency) group. That precondition is now written
down, along with what breaks it — a backfill writing a historical `accrued_at`
would turn it into a silent underpayment.

🔴 MY OWN MUTATION SWEEP WAS THE REAL FINDING, and it is the one worth carrying
forward. Every settlement test used a single-day fixture, so the loop body ran
exactly once in every test. The multi-day walk, the cursor, `maxDays` and the
continue-vs-break semantics were structurally unreachable — a mutant over any of
them would have killed nothing. Last round's "7 mutants, 7 killed" was therefore a
true statement about seven mutants that did not include the control flow that
round had just rewritten. A sweep is only as wide as the mutants you imagined.

Fixed by a `days()` fixture that drives N days, plus 7 tests: the multi-day walk,
an oversized day not blocking later days, a dropped bucket not blocking later
days, a thrown mint treated as a drop, the cursor advancing strictly, `maxDays`,
and a day that empties under the loop.

⚠️ AND THAT FIXTURE IMMEDIATELY EXPOSED A TEST-ISOLATION LEAK: `vi.clearAllMocks()`
clears call history but NOT `mockResolvedValueOnce` queues, so a day queue left by
one test was consumed by the next and the suite was order-dependent. The mocks fed
with `...Once` are now explicitly reset.

VERIFICATION
  pnpm typecheck    OK - 0 type errors
  affected suites   59 passed (4 files)
  prettier          clean
  mutation sweep    11 mutants, 11 KILLED, each by its own named test — five of
                    them loop control flow (no cursor advance, cursor absent from
                    the query, oversized-day break, empty-day break, maxDays
                    ignored) and six money path (run-day key, truncate oversized
                    day, uncaught throw, D6 collapse, ...)
  positive control  +1000 on the minted amount -> 2 tests red

Two mutants needed a second pass and both are recorded rather than quietly
re-run: `empty_day_breaks_run` SURVIVED the first sweep — a genuine gap, closed by
the new empty-day test — and `oversized_breaks_run` was SKIPPED because its
pattern matched 10 sites, which is a sweep that reports nothing while looking like
it ran.

Schema unchanged; no migration in this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): round 4 — the wrap stopped one statement short, and a false claim about retries

Round 4's verdict was SAFE TO MERGE with no blockers: the three liveness defects
round 3 set out to fix are genuinely fixed, verified by watching five mutants go
red rather than by reading the claims. What it found was four 🟡s of one shape —
the code is right and the comment claims more than the code does — plus two gaps
in my own mutation sweep.

🟡 THE RETRY CLAIM WAS FALSE, IN THE REASSURING DIRECTION. The comment said the
buzz client's "retry allowlist covers only connection-level errors — a 5xx or a
504 throws on the first response". Traced and wrong: `createTransactions` calls
`post` with no options, so `shouldRetry` is undefined, and `withRetries` reads
`shouldRetry ? predicate(...) : true` under a comment saying "Absent predicate
keeps the historical behaviour: retry everything". `isSafeToRetry` is exported
and never applied on this path. So a 5xx IS retried at the client default (3),
and a run with N payable buckets can issue up to 4N POSTs during an outage. That
paragraph was the file's only statement about mint cost, so it is what someone
would have sized a timeout or an alert against.

🟡 THE DIAGNOSTIC COULD NOT TELL PERMANENT FROM TRANSIENT. `buzzService` is built
with a `mapError`, so every non-2xx arrives as a TRPCError carrying the fixed
string "An unexpected error ocurred, please try again later". A permanent 400 and
a transient 503 produced BYTE-IDENTICAL log lines, repeated daily forever, so the
permanent one was indistinguishable from noise — and the repo already ships
`getBuzzApiStatus` to read the real status back through the wrapper. Now logged.

🟡 THE WRAP STOPPED ONE STATEMENT SHORT. The mint was wrapped; `updateMany` one
line below it was not — and the sentence justifying the wrap ("bucket 1 of N
throwing aborts buckets 2..N, the day loop and the completion log") stayed true,
verbatim, of the unwrapped call. `id: { in: rowIds }` can carry up to `limit` ids,
so a statement timeout there is not exotic. Money was safe either way; what it
cost was every remaining bucket and every later day in the run. Wrapped, with its
own log line — a flip that throws after a successful mint is the one case where
money moved and the rows do not say so.

🟡 THE CURSOR BOUNDS BLOCKING WITHIN A RUN, NOT ACROSS RUNS — and round 3's
comment claimed the stronger property ("nothing is abandoned permanently"). Both
stuck-day arms are permanent: an oversized day is unsettleable until someone
raises `limit`, and a persistently-rejected owner's rows stay accrued forever.
Each is re-selected on every later run, so 30 accumulated stuck days would consume
the whole budget and settlement would stop for everyone — the same end state the
cursor was added to prevent, reached 30 days later. `maxDays` now counts only
PRODUCTIVE days, with `maxIterations` (default maxDays * 4) as the absolute bound.

🟢 Removed an unkillable clause rather than leaving it to read as a guard:
`threw === null &&` in the `moved` expression. `mint` is assigned only inside the
`try`, so it is null on every throw path and the expression was already false
there — measured, deleting it left the suite green.

🟢 Completed the test reset list: the comment claimed EVERY `...Once` mock was
reset, while `create` — fed `mockRejectedValueOnce` twice — was not.

🔴 TWO GAPS IN MY OWN SWEEP, both closed, both worth recording:
  * `maxIterations`'s DEFAULT was unpinned — the only test exercising the cap
    passed one explicitly, so a mutant replacing the default SURVIVED.
  * `threwStatus` was unpinned — a mutant nulling it SURVIVED.
  And the first fix was initially WORSE than the gap: feeding the same stuck day
  forever "killed" the mutant only by HANGING the suite — 43s, `tests 0ms`, no
  named failure, indistinguishable from a CI timeout. It was also a mock artifact:
  in production the cursor is monotonic and bounded by `boundary`, so an unbounded
  default could never hang. The day supply is finite now and the mutant fails an
  assertion in 5ms.

⚠️ AND A REPORTING CORRECTION I OWE: round 3's sweep was reported as "11 killed,
each by its own named test". Two of the five loop mutants share one killer, so the
independence claim was overstated. This round's sweep prints the FULL killer list
per mutant rather than the first.

VERIFICATION
  pnpm typecheck    OK - 0 type errors
  affected suites   64 passed (4 files)
  prettier          clean
  mutation sweep    9 mutants, 9 KILLED. Killer counts reported rather than
                    assumed: flip-unwrapped 1, stuck-days-toward-budget 1,
                    maxIterations-default 1, threwStatus 1, mint-throw 1,
                    D6 collapse 1, no-cursor-advance 1, run-day key 5.
  positive control  +1000 on the minted amount -> 2 tests red

Two mutants needed a precise anchor rather than a shell pattern (18 and 10
incidental matches respectively) — a sweep that SKIPS reports nothing while
looking like it ran, which is the same failure shape as a silent zero.

Schema unchanged; no migration in this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(app-blocks): round 5 — the productive-day fix covered one of the two arms it named, and a test I reported as closing a gap did not

Round 5 returned three 🟡s. Two of them refute claims made in the previous
round's commit message, and I verified both against the code before acting.

🟡 F1 — THE PRODUCTIVE-DAY COUNT DELIVERED HALF OF WHAT ITS COMMENT PROMISED.
`productiveDays += 1` fired as soon as a day had rows, BEFORE a single mint was
attempted. The comment enumerated two permanent stuck arms and said the count now
excluded both. Only the oversized arm was excluded — it `continue`s above the
increment. The persistently-rejected-owner arm, named verbatim in the same
comment, reached the increment and was counted productive even when every bucket
dropped and zero rows flipped. So one permanently-rejected owner still accrues one
stuck-but-"productive" day per day, and thirty of them exhaust `maxDays` on stuck
days alone — settlement stops for everyone, which is the failure the count was
added to prevent. The test named for that behaviour only ever exercised the
oversized arm (`limit: 1`), so it passed while its own arm was uncovered.
Now counted AFTER the bucket loop, conditional on something landing, and pinned by
a test that drops every bucket on two days and asserts the third still settles.

🟡 F2 — THE MOTIVATING EXAMPLE WAS THE ONE CASE WHERE THE CLAIM IS FALSE. The new
`threwStatus` comment said "a permanent 400 and a transient 503 produce
BYTE-IDENTICAL log lines". `mapError` names 400, 404 and 409 explicitly ("Your
request is invalid", "Not found", "There is a conflict with the transaction"), so
those three were already distinguishable. The fix is still worth having — 401,
403, 408, 429, 500, 502 and 503 all fall to `default` and genuinely are identical,
so a permanent auth failure versus a transient outage was the real
indistinguishable pair. The example was wrong, not the reason; corrected rather
than reworded.

🟡 F3 — A TEST I REPORTED AS CLOSING A MUTATION GAP DID NOT CLOSE IT. The
`threwStatus` test used a plain Error carrying a `status` property and asserted
`toHaveProperty('threwStatus')` — a single-argument EXISTENCE check.
`getBuzzApiStatus` returns undefined for that shape, so the field logged `null`,
and the mutant `threwStatus: null` SURVIVED while the test passed. Re-measured
here before fixing: mutant applied, 30/30 still green. The previous commit and PR
comment both recorded that gap as closed. It now uses a real `BuzzApiError` inside
a `TRPCError` cause — the shape production actually produces — and asserts the
VALUE is 503.

🟡 F4 — WRAPPING THE FLIP MADE ITS FAILURE SILENT. Before the wrap an `updateMany`
throw failed the job and was loud; after it, the run returns success with only an
Axiom line. A systemic flip failure would report `rowsSettled: 0` on a job that
says it succeeded, every night, while money left on day one of each bucket. Added
`flipFailures` to the result and the job log — the one counter that means money
moved without a settled row — and put the amount on the log line, which carried
`settlementKey` but not the sum.

🟢 F5 — the flip-failure message asserted a state the code cannot observe. A
connection drop after the UPDATE commits raises with the rows already flipped, so
"money moved, rows still accrued" would be the opposite of the truth on a line an
operator acts on. Now "mint landed, flip did not confirm".

🟢 F6 — the `buzzMinted` comment claimed `count > 0` distinguishes "a payment this
run did not make". It does not: it cannot tell a fresh mint from a conflict on a
key an earlier run already paid, and the wrapped flip makes that path ordinary.
The cross-run total stays correct, so this is reporting, not money — stated rather
than reworded, because the exact claim is what a reader would rely on.

VERIFICATION
  pnpm typecheck    OK - 0 type errors
  affected suites   65 passed (4 files)
  prettier          clean
  mutation sweep    3 mutants re-run against the new guards, 3 KILLED, each by its
                    own named test: threwStatus nulled (this one SURVIVED before
                    the fix — re-measured, not assumed), productiveDays counted
                    unconditionally, flipFailures not counted.

⚠️ SCOPE OF THAT SWEEP, STATED BECAUSE THE LAST FOUR ROUNDS OVERSTATED THEIRS: it
covers the three guards this round added or repaired. The nine mutants from round
4 were not re-run.

Schema unchanged; no migration in this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* refactor(app-blocks): split slice 2 — this PR is the ACCRUAL LEDGER only, settlement goes to 2b

The settlement rail has no consumer. `accrueBlockAuthorFee` has zero callers on
main, so the table it writes accrues nothing, so the daily job it feeds settles
nothing — a cron, a Buzz-minting path and 25 tests whose first real exercise
would be the day a charge path lands. That is a lot of unexercised money-moving
code to carry through review on the strength of a table that is empty by
construction. It moves to `zach/app-blocks-author-fee-slice2b` and comes back
with the viewer-charge path that gives it rows.

What leaves this PR:
  - `settleBlockAuthorFees`, `SettlementBucket`, `SettleBlockAuthorFeesResult`,
    `utcDayStart`, `keyForBucket`
  - `src/server/jobs/settle-block-author-fees.ts` and its registration in the
    run-jobs webhook (reverted to the main version verbatim)
  - the 25-test settlement suite

What stays — the ledger, which is the part that has to exist first:
  - the migration, unchanged and NOT re-timestamped; it is already hand-applied
    to dev and all three prod instances
  - `accrueBlockAuthorFee` and the six tests covering it
  - the `bafa_` id helper
  - the call-site-ledger registration and the migration-comment filtering fix

RENAMED `author-fee-settlement.service.ts` to `author-fee-accrual.service.ts`.
A file named for settlement that settles nothing is the same defect this PR's
own audit ladder found four times in rounds 4 and 5 — a name claiming more than
the code does. The call-site ledger keys on the file path and fails on both
GROWTH and SHRINK, so its entry moved with the file.

`STATUS_SETTLED` is now exported despite nothing in this slice reading or
writing it. It names one of the two states of a CHECK-constrained column this
slice's migration ships, and 2b is the writer; exporting it keeps one spelling
of the literal across both slices rather than letting 2b re-declare it, where a
typo would match no row and fail silently. Its comment says so, so its presence
is not read as evidence that anything settles.

Kept as a commit on top rather than a force-push: the six audit rounds behind
the settlement code are the record of how it got correct, and 2b inherits that
history. The PR diff is computed against the merge base, so what reviewers see
is the ledger-only slice either way.

Rail is still DARK: 0 callers of `accrueBlockAuthorFee`, flag
`app-blocks-author-fee-enabled` is `enabled: false`.

Tests: 4323 -> 4298 in the blocks suite, exactly the 25 settlement tests, moved
not deleted. The four-suite run goes 65 -> 40. Typecheck 0 errors; the 43-file
lint-rules suite is green at 599.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 16:09:08 -05:00
Justin Maier 8aa9e5cef2 revert(remix): drop the verify hint from the remix menu (#4951)
Justin reviewed it on a dev server and does not want it there. The menu is
back to exactly its pre-#4939 state — same three options, same labels, no
per-option annotation.

What stays from that PR is remixClaimState in utils/remix-claim.ts, which
has no user-visible surface and is what keeps the 0.75 rule to one
derivation for the free-path work.

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:58:52 -06:00
Justin Maier c4c402ef40 fix(search): page the models delta scan by keyset, not OFFSET (#4938)
* fix(search): page the models delta scan by keyset, not OFFSET

prepareBatches walked the set of models updated since the last run with an
unordered OFFSET/LIMIT loop. The set is re-evaluated on every page and its
membership moves while the scan runs: an edit that unpublishes a model, or
flips it to Unsearchable, removes a row from under the cursor and shifts every
later page down by one, so a model eligible for the whole scan is silently
never indexed. At ~1,659 published edits per day a multi-page scan meets that
routinely, and this loop is also what an index repair leans on.

Ordering the OFFSET query would not have fixed it -- order was never the
problem, membership was. Page by a forward-only id cursor instead, which is
unreachable by a membership change because ids are immutable.

prepareBatches is hoisted to an exported prepareModelsBatches, mirroring
prepareUsersBatches, so the paging can be driven by a test.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): make the keyset paging fake refuse what it cannot read

Review found the fake answered queries it had not understood, so three
mutations of the production query passed:

- ORDER BY id DESC passed all four cases. The fake sorted ascending in both
  arms whatever the SQL asked, and /ORDER BY id/ matches DESC. Against a real
  database that mutation walks the cursor backwards from the top of the table
  and the scan never terminates.
- A literal LIMIT 2000 fell through to a members.size default, handing back the
  whole set on page one -- at which point the headline case passes under an
  OFFSET implementation too.
- id >= instead of id > fell through to an uncursored read and reddened with
  "the scan is not advancing", which is not what that defect does.

The fake now honours ORDER BY direction and throws on a query whose LIMIT or
cursor it cannot find. Case 1 also asserts the mid-scan edit actually landed --
keyset is meant to be unmoved by it, so nothing else in that case could tell a
dead mutation from a working one -- and pins batchSize against the production
constant so page-size drift names itself.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): pin the page query's eligibility predicates

The paging fake models membership as an opaque set of ids, so it cannot see
which rows the WHERE clause selects. Two review lanes independently
demonstrated the consequence: deleting any one of the three predicates left
the whole suite green. Dropping the updatedAt bound turns the delta scan into
a full scan of every published model every 15 minutes; dropping the
availability bound puts Unsearchable models into the public index. Both are
the shape of an ordinary WHERE-clause tidy-up, and this is the only test that
reads this query.

Pinned textually rather than by teaching the fake to carry per-row timestamps:
one assertion covers all three predicates where a behavioural fixture would
only cover updatedAt, and a smaller fix round is the safer one.

The watermark comment now names the symbols it depends on instead of
restating base.search-index's semantics, which would rot silently if that
file changed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): pin the page query by whole clause, values, and the empty page

Round three of review found three mutations of the query that left the suite
green. Substring assertions were the common cause.

- Widening. Appending `OR availability = 'Unsearchable'` leaves every asserted
  fragment present while AND binds tighter than OR, so every Unsearchable model
  is returned on every page. The sibling test next door already carried a
  written record of an adversarial round that beat this same assertion shape,
  so its `norm`/`renderTag`/`whereClausesOf` helpers move to
  `sql-shape.test-utils` and both files now pin a whole normalised clause with
  toBe rather than substrings.
- Value corruption. `renderTag` renders a bind param as `?`, so the clause is
  blind to values and `Availability.Unsearchable` -> `Private` was green.
  The page query's bind values are pinned separately.
- The empty-page break was unreachable: every fixture ended on a short page, so
  deleting `if (!ids.length) break` left the suite green while production reads
  `ids[ids.length - 1].id` off an empty array and kills the index job on any run
  where the eligible set is an exact multiple of the page size, or empty. A
  fixture of exactly READ_BATCH_SIZE members reaches it; the deletion now fails
  with that same TypeError.

No production change in this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): pin that the processor runs the function under test

Round four of review demonstrated the cheapest possible revert of this PR:
leave the exported prepareModelsBatches alone and re-inline the old OFFSET
body at the wiring site. Production goes back to row-losing paging and all 17
tests stay green, because every case imports the exported function directly
and nothing in the repo read modelsSearchIndex.prepareBatches. The test file's
own header claimed such a revert would redden it. That was false.

createSearchIndexUpdateProcessor now returns prepareBatches, alongside the
updateSyncChunkSize it already exposed for the same reason, and the test
asserts the processor runs the function it drives.

Measured cost, recorded in the test: a behaviour-preserving wrapper at the
wiring site also fails this. That is inherent to an identity assertion, and
the fix is to keep the wiring a direct reference.

Also drops `toBe` from the sql-shape docstring, which named an assertion
neither of its two callers uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): pin the bounds query and the id range it returns

prepareModelsBatches returns four things; this file pinned two. Nothing read
startId/endId, and the fake short-circuited the MIN/MAX statement on
text.includes('MIN(id)') and handed back a hardcoded row, so the whole bounds
query was invisible. Review demonstrated two mutants that left every case
green:

- swapping the aliases to MAX(id) as "startId", MIN(id) as "endId" makes
  endId - startId negative in base.search-index's range fan-out, so newly
  created models silently stop being indexed on every run;
- deleting the bounds query guts the full rebuild, which then indexes zero
  models while the case named "issues no page query at all on a full rebuild"
  stays green, because it only asserts no page query fired.

The fake now answers the aggregate the statement asked for, rather than
returning fixed numbers under fixed names, so an alias swap produces a
different id. The bounds statement's WHERE clause and binds are pinned the
way the page query's already were. The "createdAt" bound there against
"updatedAt" on the page query is pre-existing and deliberate, and is now
pinned so a one-word change between the two cannot pass unnoticed.

The file header claimed the fake refuses a query it cannot read. The bounds
query was the one place it defaulted instead, which is why this survived six
rounds; the header now says what the fake actually does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(search): pin the rebuild path's bounds, and both statements' table

The rebuild branch was the one place startId/endId are the entire output, and
the only case on it asserted that updateIds was empty and no page query fired.
Both of those are also true of a rebuild that does nothing: returning
{ startId: 0, endId: 0, updateIds: [] } early for a missing watermark passed
every case, and makes base.search-index's range fan-out zero tasks, so a whole
index rebuild creates no batches and indexes nothing.

Neither statement's table was pinned either. whereClausesOf captures from WHERE
onward, so FROM "Model" -> FROM "ModelVersion" in either query left the file
green while the scan paged a different entity.

Also corrects the header's frequency claim. It said a multi-page scan meets
concurrent edits as a matter of routine, citing ~1,659 edits a day; at a
15-minute cadence that is ~17 rows against a 2,000-row page, which argues the
single-page case. The PR description was corrected for this and the source was
not, which left the retracted claim in the file the next reader actually opens.
The corrected text also states the mechanism that does not need an edit at all:
no ORDER BY over a parallel seq scan lets synchronize_seqscans cut successive
pages out of different orderings.

That paragraph first shipped broken, because a cron string in a block comment
closes it. The comment now says so rather than leaving the next person to
rediscover it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:57:08 -06:00
briant c6da1a2dc4 fix(ingestion): stop the retry cron double-submitting a scan already in flight
The Image INSERT trigger queues a new image immediately, but ingestImage stamps
scanRequestedAt only once its upload-path submit returns. A cron run landing inside
that window read the NULL as "never submitted" and submitted a second workflow for
the same image.

Measured on prod 2026-09-18: of 41,865 images scanned in 12h, 166 carried two
workflow ids; 161 of those were created within 30s of a cron tick, against a 9.8%
uniform baseline, spread over 112 users.

A Pending image with no scanRequestedAt is now deferred for SUBMIT_IN_FLIGHT_GRACE
minutes. It stays in the JobQueue while deferred — without that it prunes as stale
and an image whose submit died silently would never be re-driven, which is the
trigger's whole purpose. The deferral count is reported alongside waitingForRetry.

Two existing fixtures modelled "new image, never submitted" as createdAt: now, which
the grace defers; aged them past it so each test still exercises its own subject.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-18 13:36:01 -06:00
Justin Maier d7038c5aa8 fix(home-blocks): hydrate the viewer's own reactions on cached image blocks (#4935)
* fix(home-blocks): hydrate the viewer own reactions on cached image blocks

Home block payloads are stored in one Redis entry with no user segment AND served through
edgeCacheIt with canCache left true, so every viewer is handed the same image objects with
`reactions: []`. reaction.toggle acts on the database row rather than on what is drawn, so a
viewer whose reaction shows un-highlighted clicks it and deletes the reaction they already had.

Keeps the shared payload anonymous and hydrates on the client instead: a new
reaction.getMyImageReactions procedure, and a useHydratedImageReactions hook that the three home
blocks rendering ImageCard call on the list they hand to ImagesProvider, which is the same window
the image detail dialog browses.

The query and its grouping already existed twice inside image.service.ts; both now call one
exported getUserReactionsForImages, which the new procedure is the third caller of.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(home-blocks): make the hydrated list the only one a block can render

Review found the guard proved the blocks MENTION the hook, not that the hydrated array reaches
the cards: keeping the call and rendering a second, un-hydrated binding restored the bug in full
with the suite green. Fixed structurally rather than by a stronger string match. The hook result
is now passed straight into useDedupedCappedItems, so the capped list is the only array in scope,
and the guard asserts that shape. Hydrating before the cap also stops the query key churning as
earlier blocks publish their dedupe claims.

Also from review:
- reactionQueryChunks is pure and exported, so the signed-out gate has a test. Losing it is one
  UNAUTHORIZED request per home block for the majority of front-page traffic.
- The chunk size and the input schema's max are two copies of one number; a test pins them equal.
  Divergence fails zod, React Query swallows it, and the grid silently stays un-hydrated.
- `reactions` is optional on the hook's item type, so the collection blocks pass their union in
  without a cast, and the merge reads it with `?? []`.
- chunkIds moves to array-helpers; sticker.util, StickerPlacementBatchProvider and
  RemixGalleryBatchProvider had three copies of one body between them.
- Dropped the `toContain('useHydratedImageReactions')` assertion: deleting the call leaves the
  import, so it barely fails. The structural assertion subsumes it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(home-blocks): correct the measured cost, and stop re-asking per mount

Round 2 of review. The cost model I wrote into the hook's doc comment was wrong in the direction
that understates it: a FeaturedCollections block renders one section per pick and its renderCount
is 5, so its picks do not share a call, and one of the three Feed blocks carries models and asks
nothing. Nine requests on today's prod home page, not six. The per-request figure was measured at
the post-cap shape this branch replaced; over a block's whole pre-cap pool it is 0.4-1.2 ms, about
4.6 ms of replica time for the page. Both numbers corrected rather than dropped, since a follow-up
ticket quotes them.

Two behaviour changes, both from the same review:
- The ids are sorted before chunking, so a repeat visit can reuse the answer. Every block shuffles
  its pool on mount, which made the query key fresh every time and `staleTime` nearly inert. The
  sort lives here and NOT in `chunkIds`, whose other callers page and need insertion order.
- Hydration waits on hidden preferences. `useApplyHiddenPreferences` does not block, so between
  the payload landing and the preference maps resolving it hands back the unfiltered pool; asking
  about that first spent a whole extra round of requests per cold load.

The guard now accepts either nesting order. The property that kills the un-hydrated binding is the
inlining, not which hook is outermost, and hydrating after the cap is a defensible shape this
guard has no business forbidding - it was this branch's own design one commit ago. Its comment
also stops claiming the bug is impossible to write: `filtered` is still a binding. What the guard
removes is the INVITED mutation.

The router-rung assertion is an allow-list of authed procedures rather than one spelling, so it
reds on a loosening and not on a tightening.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(home-blocks): take the entity, not a boolean the caller derives

Review found a green mutant against the subject: flipping `{ enabled: !loadingPreferences }` to
`{ enabled: loadingPreferences }` switched hydration off on every render that draws a card, because
the blocks return a skeleton while that flag is true. Every check in this repo stayed green - the
guard reads the composition rather than the argument's value, and the pure tests prove the gate
behaves correctly with the boolean it is handed, never that the boolean handed to it is right.

So the boolean is gone rather than guarded. The hook takes the entity type, required and typed, and
derives `entity === 'image'` in one place the existing pure tests already reach. A mistyped
'images' is now a compile error; there is no default to flip; and `type === 'image'` is no longer
restated at three call sites.

The `!loadingPreferences` half is deleted outright rather than moved. It was a no-op:
`filterPreferences` returns `items: []` while preferences load, so the hook was already being handed
an empty pool, never the unfiltered one. The comment claiming otherwise was the stated rationale for
the gate, duplicated in all three blocks.

The rung check reads every occurrence rather than `match`'s first. A doc comment above the
declaration that quotes it - the natural thing a future editor writes - would otherwise satisfy a
first-match check while the declaration underneath said something else.

Three comment corrections, all of them claims that had stopped being true:
- The blocks said inlining left "the only array in scope". It does not; `filtered` is still there.
  What inlining removes is the INVITED mistake, which is the one that happens.
- The pre-cap move was justified partly on the detail dialog browsing the wider window. It does not:
  all three blocks hand `ImagesProvider` the capped list. The decision rests on the re-keying alone.
- `chunkIds`' docstring argues against sorting and now has a caller that sorts first; it says why.

The randomised shuffle control is reversed instead, so it is certainly red rather than almost surely.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(home-blocks): cover the hook body, which nothing in the repo executed

Two lanes independently found the same class: `reactionQueryChunks` and `mergeUserImageReactions`
are well covered, and the composition between them was covered by nothing. Three one-token edits
inside the hook body switch hydration off on the front page with typecheck, eslint, the guard and
the whole unit suite green - the `byImageId` memo dep, the final memo's dep list, and the
`query.data ?? {}` fallback.

Nothing else can see those. `react-hooks/exhaustive-deps` arrives as a WARNING via
next/core-web-vitals, `lint` is `eslint src/` with no `--max-warnings`, and the CI workflow says so
out loud - "Errors only (no --max-warnings): the repo has 3,470 warnings". So dependency-array
correctness has no automated enforcement in this repo, and one of the two dep lists sits under an
`eslint-disable-next-line react-hooks/exhaustive-deps`, which makes "clean up the disabled rule" an
ordinary edit for someone who has never read this ticket.

The test renders the hook with a stubbed `useQueries`, asserts the un-hydrated state
synchronously as a negative control, then makes the queries report data and asserts the hydrated
state. It asserts a state that ARRIVES, so there is nothing on a timer to race.

Two things this round got wrong first and are worth recording:
- The stub originally returned its canned results whatever the hook asked for, so the non-image
  case passed data to a surface that had issued no query. It now returns one result per chunk, as
  the real `useQueries` does. The negative control is what caught it.
- The first sort-direction assertion, `chunks[0][0]).toBe(1)`, could not fail: 1 sorts first
  lexicographically too. Measured, not assumed - the mutant stayed green on that line. It now
  asserts where the two orders actually disagree, at the second element.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(home-blocks): make the query stub faithful in content, not only in count

Follow-up on the hook-body test, from the same lane that asked for it. The stub kept the
descriptor COUNT honest and discarded what was in them, so `{ imageIds: chunk }` changed to
`{ imageIds: chunks[0] }` stayed green — and in production that makes every chunk after the first
ask about the first one's ids, so the back half of a large grid never hydrates and its cards go
back to deleting on click.

The stub now keeps the descriptors and a test asserts the hook asked each chunk about its own ids.
Control: that mutation reds with `expected [ 100, 100 ] to deeply equal [ 100, 50 ]`.

That path is unreachable on today's config — `FEED_FETCH_CEILING` and the collection limit are
both 100, which is `REACTION_FETCH_CHUNK`, so a block's pool is always exactly one chunk. The test
is there for the day one of those numbers goes up, which is a one-token edit nothing else connects
to this hook. The comment says so rather than implying live coverage.

Three smaller things in the same family:
- The stub mapped one result per descriptor instead of slicing. A `queryResults` shorter than the
  descriptor list silently handed the hook a shape `useQueries` cannot produce.
- `queryResults` is reset in `beforeEach`. Inheriting a previous test's value would inherit it as
  HYDRATED data, which is the direction that produces a false green.
- `images` being hoisted out of the probe is load-bearing for the dep-list control — a fresh
  identity each render makes the memo recompute regardless — and nothing said so. Now it does.

The `entity: 'model'` test keeps its assertion and loses its claim: the stub is what withholds the
answer there, so it cannot tell a hook that filters from one that never asked. What it does prove
is that the gate survives the whole body, which the pure tests next door cannot.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(home-blocks): assert what came back, not only what was asked

Round 6. The multi-chunk test proved the hook asked each chunk about its own ids and never looked
at the hook's output, so the response half of the same shape was open: collapsing
`Object.assign({}, ...queries.map(q => q.data ?? {}))` to `queries[0]?.data ?? {}` was green, and
in production that drops every chunk after the first — images 101+ render un-hydrated and the
first click deletes. It now asserts both ends, and the assertion on the LAST image is the only
place in the suite where a chunk other than the first has to land.

Same round, same root cause one argument to the right: the stub captured the descriptor's input
and discarded its options, so deleting `{ staleTime: 60_000 }` was green. That is the option the
round-3 sort exists to make worth having — a repeating query key buys nothing if nothing holds the
answer — and the pure sort tests structurally cannot see it, because it lives in the hook. The
stub now captures both arguments and the test pins it.

Controls, applied and reverted:
- `Object.assign(...)` -> `queries[0]?.data ?? {}`: red, `expected [] to deeply equal [ { userId: 9266475, … } ]`
- `{ staleTime: 60_000 }` deleted: red
- `{ imageIds: chunk }` -> `{ imageIds: chunks[0] }`: red, `expected [ 100, 100 ] to deeply equal [ 100, 50 ]`

Multi-chunk remains unreachable on today's config — both ceilings are 100, which is the chunk size
— and the test says so. The point of closing both halves rather than one is that the asymmetry
would be invisible to whoever raises that number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(home-blocks): cover the render production actually performs first

Round 7 found the third dependency array, and unlike the multi-chunk pair this one is reachable
today on every page load. `useApplyHiddenPreferences` returns `items: []` unconditionally while
hidden preferences load, so every home block hands the hook an EMPTY array on its first render and
the real pool on a later one. Drop the id join from the `chunks` memo's deps and it freezes at
`chunkIds([], 100)`: nothing is ever asked, the merge is a permanent no-op, every card renders
un-hydrated, and the first click deletes. Typecheck, eslint, the guard and all three existing
hook-body cases stayed green — they all start with a NON-EMPTY pool, so a frozen `chunks` is
frozen at the right value in every one of them.

The new case mounts empty and populates on rerender, which is the sequence production performs.

It is deliberately a separate case rather than an amendment to an existing one, and the file now
says why: the fixture decides which dep list a case can see. A stable `images` binding can see the
`byImageId` and final memos and cannot see `chunks`; a growing `images` reaches `chunks` and cannot
see the final memo, because a fresh identity makes that one recompute regardless of its deps. The
two shapes are mutually blind, so folding them together would close one and silently unarm the
other.

Controls, applied and reverted, all three dep lists at once to prove the new case disarmed neither
of the existing ones:
- `chunks` deps -> `[userId, entity]`: red on the new case
- `[images, byImageId, userId]` -> `[images, userId]`: still red
- `byImageId` deps -> `[queries.length]`: still red

Also renamed the stub's captured parameter from `imageIds` to `input`, since the first descriptor
argument is itself an object with an `imageIds` key and `d.imageIds.imageIds` read like a typo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(home-blocks): pin the pool CHANGING, not merely growing

Round 8. Keying the `chunks` memo on `images.length` instead of the id join was green against all
four cases, because every one of them either holds the pool still or only grows it. In production
`useApplyHiddenPreferences` hands back the PREVIOUS items during a refetch and then the new ones,
and a home block's payload size comes from its config rather than its content — so a same-sized,
different-membership swap is the ordinary refetch, not an exotic one. Under that mutant the hook
keeps asking about the previous ids, the new images render un-hydrated, and the first click
deletes.

Closed by extending the existing empty-then-populated case with a same-length swap rather than
adding a fifth fixture, so it disarms nothing.

The fixture rule in the file's doc comment is restated to one that generalises: `chunks` has three
deps and each needs something to VARY across a rerender to be visible at all, while the other two
memos need something to HOLD STILL. "Stable versus growing" read as an exhaustive pair and is not
one — chunk count is its own axis, and `userId` and `entity` are axes no case varies today, since
`useCurrentUser` is a constant mock. Dropping either of those from the `chunks` deps is green
against every case in this file. Recorded in the comment rather than quietly left out.

Controls, applied and reverted, all five at once so a new case cannot silently unarm an older one:
- `chunks` deps -> `[images.length, userId, entity]`: red on the extended case
- `chunks` deps -> `[userId, entity]`: still red
- `[images, byImageId, userId]` -> `[images, userId]`: still red
- `byImageId` deps -> `[queries.length]`: still red, two cases
- response merge -> `queries[0]?.data ?? {}`: still red

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(home-blocks): assert the non-image case asks for nothing, as its name says

Round 9 found no green mutant. Its one free item: the non-image case asserted only the hook's
OUTPUT, never that no query was issued — which the pure `it.each` next door already covers. Its
name has promised the stronger thing for four rounds, and the `asked` capture that makes it
possible arrived two rounds after the case was written and the case was never revisited.

With the assertion it is the only place pinning that no query is issued outside `chunks`.
Control: removing `entity !== 'image'` from the gate reds it with
`expected [ Array(1) ] to deeply equal []`.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 13:27:06 -06:00
Justin Maier 0340f692bf docs(tests): correct the seam guard's prose, and the false absolutes each fix introduced (#4940)
* docs(tests): correct the seam guard's header and cut what argues rather than informs

The header claimed "you cannot call the function without importing the module it
lives in". A consumer reached through a re-exporting barrel matches neither half
of the detector; what saves the ledger is that the BARREL matches the `from`
clause and joins it, one file away from the consumer that gates. Stated, with
the limit, because a reader trusting the absolute would stop looking.

Cuts the dated "85/85 green" count, the change-log narration of what the ledger
used to be, the summary of the assertions below it, and a clause arguing the
guard is correct. The mutants are the proof now; the header does not need to
make the case.

Comment-only: the diff contains no non-comment lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(tests): restore the truncated clause on the corpus-loop control

The comment above `expectedCorpus` ended mid-sentence, dropping the half that
explains why scoping the corpus loop is green: no detector fixture can observe
that loop at all, because `verdictFor` seeds `SOURCE` directly and runs past it;
and every corpus member already carries the token such a filter would scope by,
so the filter excludes nothing. Green there means inert, not caught, and the
re-derivation below is what would actually disagree.

Comment-only: the diff contains no non-comment lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(tests): correct the detector limit and move it onto the code it constrains

The header said a consumer reached through a re-exporting barrel matches neither
half of the detector. It matches the symbol half: a name-preserving re-export
still writes `classifyGatedImageForViewer(`, so the consumer joins the ledger at
its own path. Only a barrel hop that also renames escapes both. That mattered
more than wording, because the barrel case is the symbol half's ONLY unique
contribution - an alias, a namespace import and a re-export from the logic module
all carry the `from` clause - so the header handed a future tidier an argument
for deleting it.

The corrected statement lives on `isCallSite` rather than in the header, where it
sits beside the expression it describes instead of drifting from it. The same
docblock justified the symbol half as catching a namespace import or a re-export,
both of which the import half already catches.

The header keeps the rule and drops what restated it: the detection mechanism
(stated twice more, on `LOGIC_MODULE_IMPORT` and `isCallSite`), the paragraph
arguing a per-file suite could not catch this, and a summary of two sibling
suites' assertions - which also over-claimed, since the grid withholds an unrated
image's url from its author too when the image is flagged or scan-refused.

The corpus-loop comment now states the fact rather than the mutation-testing
note: every corpus member's path contains the token, so narrowing the loop by it
excludes nothing.

Comment-only: the diff contains no non-comment lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(tests): state the detector's escape shapes as a property, not a list

The previous round's docblock said only a renaming barrel hop escapes both
halves. It does not. A renaming dynamic import escapes both with no barrel
anywhere: `await import('…logic')` carries no `from` clause for the import half,
and a renamed destructure puts a `:` where the symbol half needs `(`. The file
still enters the corpus, is scanned, and comes back not-a-call-site. A helper
handed the function as a value escapes the same way.

That distinction is the safety-relevant part and it is now stated: a renaming
barrel hop reddens this suite at the barrel, while a renaming `import()` or a
helper reddens nothing at all. Written as a property of what escapes rather than
an enumeration of shapes, so finding a fourth shape does not make it false again.

Restores the clause saying each file type-checks and each file's own suite passes.
It was cut as self-justification, but it is the only statement of why the two
per-consumer suites cannot substitute for this one, it lives nowhere else, and
being about the nature of cross-file defects rather than about any code, it cannot
drift.

Comment-only: the diff contains no non-comment lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(tests): state both halves as spellings, and stop claiming the per-file suites are blind

Two false statements, both introduced by the previous two rounds of this branch.

The restored clause said each file type-checks and each file's own suite passes,
so this is the class of defect no per-file suite can see. Not true of the tree as
it stands: `block-post.service.test.ts` has an `it.each` whose first two rows are
`{ ingestion: 'Pending' }` and `{ nsfwLevel: 0 }`, both reaching the gate at
`block-post.service.ts:592`, so rewriting that gate as `=== 'hidden'` fails them.
It was true when the seam was created and stopped being true when those cases
were written. The narrower statement is the one that does not rot: neither file is
wrong on its own, so nothing fails until someone writes a per-consumer case for
the new state - which is exactly what a third consumer would not have.

The escape condition said a file escapes both halves by naming neither the module
nor the symbol. A renaming `import()` names the module in full and escapes anyway,
because the import half keys on a `from` clause rather than on the module's name -
so the condition excluded a case the same paragraph listed two clauses later.

Both halves are now stated as what they are, spellings, with the `from` forms left
to the regex's own docblock instead of restated fifty lines away. Every false
absolute on this file has been a claim about that regex written far from it.

Comment-only: the diff contains no non-comment lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* docs(tests): cut the detector's consequence claims, and make its referent exact

The docblock claimed that when an escape shape lands in an already-ledgered file,
the `status !== 'visible'` containment still holds the line. False for half the
ledger: `targets` is `EXPECTED_CALL_SITES` minus `MAY_BRANCH_ON_HIDDEN`, pinned by
name to `block-post.service.ts` alone, so the grid projection is ledgered with no
content assertion against it at all. The claim read as a backstop that does not
exist for the one consumer allowed to branch on `=== 'hidden'`.

Its companion - that a barrel hop still reddens at the barrel - was unconditional
in the same way: a barrel re-exporting via an extension the regex does not list
matches neither half itself, so that hop reddens nowhere either.

Both are deleted rather than qualified. This is the fifth false statement in this
docblock in four rounds, every one of them a consequence claim about a text
matcher; a deletion is the only edit here that cannot produce a sixth. What
remains is the part that has survived every round: each half pins a spelling, and
a file writing neither is not a call site.

That sentence defers to `LOGIC_MODULE_IMPORT`'s own docblock for the forms, which
makes it load-bearing, and it under-described them - it named the rooted, relative
and extensionless spellings while the regex also accepts `.ts`, `.tsx`, `.js` and
`.jsx`. A reader following the pointer to check a `.js` specifier was told by
implication it was not covered. Now stated exactly, with its closed end.

Comment-only: the diff contains no non-comment lines.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:41:10 -06:00
Justin Maier e50d122cb2 feat(remix): say at click time which remix modes we can verify (#4939)
* feat(remix): say at click time which remix modes we can verify

A prompt-reuse remix feeds no image to the job, so the server resolves no
sourceImageIds and the remix gallery correctly refuses the free submission.
The submit modal already explains that (RemixGallerySubmitModal renders
freeUnavailableReason outside the free/paid block, deliberately). What is
missing is earlier: the three remix options look alike at the moment of
choosing, so the difference is only discoverable after generating.

Mark the two that feed the image itself. On the options that HAVE the
property rather than the one that lacks it — reusing a prompt is a
legitimate remix and the menu should not read as warning someone off it.
It says we can verify, not that the submission will be free: whether free
is on offer is five more rungs in freeSubmissionOffer, and a menu that
promised it would be overruled at the modal.

remixClaimState is split out of remixClaimHolds so the claim's outcome —
holds, carrier, why not, and the score where the prompt is the carrier —
has one derivation. The predicate is now a wrapper over it. A surface that
computed its own similarity would be a second copy of the 0.75 threshold,
and the copy that drifts is the one telling someone their remix still
counts while the submit is about to drop it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(remix): assert the reason, not just the boolean

Review round.

The comment on remixClaimState said the drift notice renders it. There is
no drift notice — it moved to the following PR — so the comment asserted a
consumer that does not exist. Corrected to state the instruction that is
actually load-bearing: this is the only derivation, reuse it rather than
recomputing the threshold.

Every existing test asserted through the holds boolean, so carrier, reason
and score could take any value and stay green. That is the half the next PR
branches on. The pair that matters is drifted against uncarried: both are
holds:false and nothing else separates them, so reporting drifted for a
cleared prompt box would tell someone they had changed their mind at the
moment they cleared it to retype.

Asserted field by field rather than with toMatchObject, which truncates to
"expected { holds: false, ...(3) } to match object { holds: false, ...(3) }"
and never names the value that was wrong. Verified by reverting: swapping
the reason on the cleared-prompt branch now fails with "expected 'drifted'
to be 'uncarried'".

Three comment blocks trimmed to the fact they carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(remix): cover the three branches a mutation passed through

Second review round found the first one incomplete.

Three branches had no assertion at the state level, so mutating them
printed nothing: the no-remix reason, the branch where the remix seeded no
prompt at all — which is a different branch from the form's prompt being
cleared, and carries nothing rather than the prompt — and the media
branch's reason.

The drifted score assertion was vacuous by fixture rather than by shape.
The two prompts share no token after cleaning, so every term in the
similarity is zero and the score is exactly 0; toBeLessThan(0.75) is then
true of any bounded wrong answer, including a constant. Replaced with an
ordering over three fixtures whose scores were measured rather than
assumed: 0.7601 for two tags changed, 0.2969 for four of eleven left, 0
for disjoint. A constant score now fails with "expected 0.1 to be greater
than 0.1".

Comments cut, not trimmed. "A server-side check is coming" was the same
unfalsifiable forward claim as the one this round already corrected, for a
PR that does not exist. The block comment over the tests restated what the
test names say and what remix-claim.ts states three lines from the branch
it describes, and the note defending the assertion style was the fix round
arguing with a reviewer inside the file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(remix): drop the comment defending the assertion, keep the calibration

The note above the ordering assertions described the previous version of
the test rather than this one, and claimed the null fallback guarded a case
that cannot occur at that call site — all three scores come from the branch
that always returns a number. The test name already says what the
assertions say.

The fixture docs keep what a reader cannot recover by eye, that the overlap
is calibrated rather than incidental, and lose the measured decimals. Those
are pinned by no assertion, so a retuned similarity would leave them wrong
with everything still green. The numbers are in the PR body, which is dated
by nature.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(remix): assert the expired branch carries nothing

Third review round, third branch whose mutation printed nothing: the
expired claim asserted its reason, its holds and its score, and not its
carrier. Verified silent by running it — 15 passed with carrier changed to
'prompt' — and now fails with "expected 'prompt' to be null".

Two comments corrected rather than trimmed. The fixture docs said eleven
tags where the seed has nine; eleven is its token count, which is what the
similarity works on, but the sentence says tags. The state doc claimed
expired and uncarried are not caused by the person, which is false for the
branch where they cleared the prompt box themselves — the inline comment
two lines below says exactly that.

The predicate's own doc restated its signature and went.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* test(remix): assert the whole state on every branch

Three review rounds each found one more field unasserted on one more
branch, a different field each time. Assertions written per branch pin what
their author was thinking about, so a fourth round would have sampled the
same hole rather than closed it.

toEqual over the whole object on every branch, table-driven. A field-level
mutation cannot survive it, because there is no field that no assertion
mentions. Verified against all five survivors the rounds found, plus a
sixth chosen on a branch none of them touched: all six red, the sixth
printing carrier prompt against media.

score is expect.any(Number) where the prompt carries the claim. Its value
is pinned by the ordering beside it, which rules out a constant and a wrong
ranking and does not rule out a monotone-but-wrong score. That is the
ceiling of an ordering property rather than a gap here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-18 11:38:30 -06:00
briant 5ff986be11 chore(moderator): release moderator-v0.0.69 moderator-v0.0.69 2026-09-18 11:03:07 -06:00