mirror of
https://github.com/civitai/civitai.git
synced 2026-09-20 22:08:18 +08:00
e50d122cb2e37fefceb2cdff8f99264a3d5d2502
26283 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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> |
||
|
|
5ff986be11 | chore(moderator): release moderator-v0.0.69 moderator-v0.0.69 | ||
|
|
6495f6f61a |
feat(moderation): minor-flag lookup + search, server-side ToS'd filter, self-harm unpublish reason
- Minor Hash Matches: a search box (model id, user id or username) filters all three tabs, and a model-id search shows that model's minor-flag state with Revert / Keep flagged regardless of the 30-day auto-flag window. An aged-out same-uploader auto-flag previously had no revert path, so the sweep kept re-applying it. (ClickUp 868m6mzbv, 868m6mw8a) - Bulk Image Manager: "Only ToS'd" / "Hide removed" now filter in the query (rows and count) instead of over the loaded page, where an account's few removed images sat thousands of rows past the window and never appeared. Getters take a BatchWindow options object. (ClickUp 868m67w6t) - Unpublish reasons: add 'self-harm' (ToS 9.6(g)) to the model and article lists. (ClickUp 868m576m8) - Docs: parity checklist, minor-hash detection doc, extraction-plan line count. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
3cc390be62 |
fix(search-index): report the ids a transform drops instead of writing nothing silently (#4933)
* fix(search-index): report the ids a transform drops instead of writing nothing silently A row that pullData returns and transformData then drops is written by nobody and reported by nobody: pushData is handed a batch that does not contain it, the task completes, and updateSync reports success. Two published models with no versions stayed stale in models_v9 through a 280k-id bulk repair and a targeted re-enqueue because of it (868m6jk7w). Count the ids a targeted batch asked for against the documents its transform produced, carry the difference to the queue, and return it as droppedIds / droppedIdSample alongside failedIds. update(), updateSync() and processQueues() each log a line naming the count and a sample when it is nonzero. An unreadable transform output reports nothing rather than everything - a false alarm on every batch would be worse than no alarm - and a processor that handles an id without writing a document opts in with getHandledIds, which collections does for the ids it prunes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(search-index): account for pull-side drops, surface the count to the caller Review round over the first commit. Five changes, each from a finding: - A targeted pull that comes back falsy at step 0 now reports its ids. users, comics, tools and metrics-images return null from pullData on an empty batch, and the base treats that as done before a transform task exists - so the worst case, every requested id producing nothing, was the one case reporting zero. Step 0 only: a falsy return at a later step can mean the step sequence ran out rather than no rows, and that is not provably a drop from here. - The mod reindex endpoint returns the numbers instead of a bare ok. It was the only production reader of the result and the surface the incident ran through. - The drop line is console.log, not console.error. The Update queue legitimately carries ids the index filters out - model-scan-result queues an Update for every scanned Draft model - so an error-level line would be nonzero by design on every cron run, which is the alarm-nobody-believes this is meant to avoid. - handledWithoutDocument is reported rather than subtracted into silence. A getHandledIds hook is the one way an id stops being counted as dropped, so that number is the only thing that would show a processor pruning wrongly. - updateSync dedupes its ids, and the accounting can no longer fail a batch: a throw from a processor-supplied hook would have retried and then reported the batch as failed, an observation destroying the write it exists to watch. Tests: the object-of-arrays case now uses divergent id sets, the collections hook is asserted as the processor's own rather than a copy of it, and the log line, the sample cap, the retried push and the dedupe each get a case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(search-index): dedupe before chunking, and pin the contract the endpoint changed Round 2 of review. The one that mattered: the endpoint change broke an existing test that no control could see. src/__tests__/pages/api/mod/update-index.test.ts asserted the success body was exactly { status: 'ok' } - a pinned decision this PR reverses on purpose - and all eight controls ran against the two search-index files, so the window never covered the third hunk. The case is renamed for the new contract and asserts the fields rather than tolerating them. - updateSync deduped BEFORE chunking, per action. Deduping per chunk left a repeated id counted once per chunk it landed in, so the guarantee held only for callers whose duplicates happened to be adjacent. Per action because the same id may legitimately arrive once as Update and once as Delete. - The 500 body carries handledWithoutDocument too, so a caller does not branch on the status code to find out how many ids produced nothing. - The drop log builds its parts, so a prune-only run reads "3 handled without a document" instead of "0 ids produced no document (sample: ); 3 handled...". - The step-0 comment named images and metrics-images as the exposure. images is retired - it reports nothing because it does nothing. The four processors that actually return null at step 0 are users, comics, tools, metrics-images. - droppedIdCount's "true total" doc carries its one exception: a batch whose accounting threw contributes 0 and says so at console.error. Tests: a cross-chunk dedupe case, a hook over an unreadable shape, a prune-only log case, an empty-log-line guard, and the accounting-throw case now pins the pushed payload, the fail-open count and the console.error. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(search-index): state the action taxonomy once, and test the delete path Round 3 of review. - The dedupe key and both updateSync filters read one `actionOf` helper. Stated separately, a third action added to the enum would get its own dedupe bucket while matching neither filter, and would vanish from the run silently. The filter half of that predates this PR; the dedupe made it a second site. - Two cases the round-3 diff had no coverage for: dedupe is per ACTION, so an Update and a Delete for one id do not collapse into one - a lost deletion is worse than the miscount the dedupe fixes - and the two Deletes still collapse into a single cleanup call. That is also the first coverage the delete branch of updateSync has had. - The joined log line, asserted on the one case where both halves are nonzero. A log emitting the handled part only when nothing dropped passed every case. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(search-index): pin actionOf where it disagrees with identity One derivation behind the dedupe key and both filters is the right shape, but it moves all three together: a mutation inside actionOf leaves a test that reaches the taxonomy only through updateSync passing. The per-action case now carries a bare id - the one input where `?? Update` is not identity - so the assertion prints [ 9 ] instead of [ 9, 10 ] when the normalisation goes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(search-index): name the number for what it counts, not for loss droppedIds implies loss. The number includes rows the index legitimately does not want - model-scan-result queues an Update for every scanned Draft model and the models pull filters to Published - so it is a population count, and an operator reading "droppedIds: 47" in a log at 2am concludes 47 repairs failed. A paragraph in the PR body does not reach that reader; the noun does. droppedIds -> idsWithoutDocument, droppedIdSample -> idsWithoutDocumentSample, droppedIdCount -> idsWithoutDocumentCount, and the comments move with them. It pairs with the existing handledWithoutDocument, so the two numbers read as ids with no document and the subset a processor claims to have handled anyway. The pre-existing droppedIds names in feed-image-existence-check.metrics.ts, auction.service.ts and image.service.ts are other subsystems' and untouched. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(search-index): stop the actionOf comment claiming a protection it does not give The docblock said a third enum action would, without the helper, get its own dedupe bucket while matching neither filter and vanish from the run. Stated together it still would: a third member returns itself from actionOf, matches neither filter, and vanishes exactly as before. The helper collapses the DEFAULTING rule, not taxonomy coverage, and the only person who ever reads that comment is someone adding a third action - it was telling them they were covered. Also retires the old noun from the spec surface: the file, its describe and the fixture constant. Keeping "drop" for the event and idsWithoutDocument for the state is the choice; a filename nobody can grep for the behaviour is not. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(search-index): one spelling of the accounting triple, and two truthful comments - The endpoint spelled the three accounting fields into both response bodies verbatim; a fourth field on the result would have landed in one only. - actionOf's docblock said "one statement" of the no-action-means-Update rule. Repo-wide that is false: SearchIndexUpdate.queueUpdate states it the other way (strict equality, no defaulting), so an item queued with no action there reaches neither bucket. The comment now scopes its claim to updateSync and names the other statement, because the reader who trusts it is the one calling queueUpdate([{ id }]) and getting silence. - handledWithoutDocument's doc names what collections calls the same set, disqualifiedIds, so an operator chasing a nonzero number in a response body knows what to grep for. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style(search-index): format the renamed spec CI treats a git-mv'd file as an added file and gates added files on prettier. The rename edits landed after the last prettier run, so the new path was never formatted at its new name. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
32121182a3 |
refactor(blocks): remove the dead platform-funded spend bounty rail (#4932)
* refactor(blocks): remove the dead platform-funded spend bounty rail
The percentage author bounty on block-initiated generation spend never paid
anything and structurally could not: the share was floor(gross_cents * pct /
100) computed per row, and a block generation grosses well under 20 cents, so
at 5% every row floored to zero. It has been superseded by the additive,
author-set, viewer-paid per-generation author fee (#4922).
Removed:
- the SPEND leg of backpayTrackedAttributions (the whole function had no
production caller, and was double-dark behind a Flipt flag that does not
exist plus SIGNED_OFF_RATE_CARD_VERSION === null)
- computeSpendShare + SpendShareResult in rate-card.ts, whose only non-test
call site was that spend leg
- app-bounty-cap.service.ts entirely (reserveAppBountyAccrual /
refundAppBountyAccrual), plus the dynamic import + await it added to every
spend-attribution write, where its own comment said it was a guaranteed
no-op because the reserved amount is hardcoded 0
- REDIS_SYS_KEYS.BLOCKS.BOUNTY_CAP, which only that module used
Deliberately kept:
- the MEMBERSHIP leg. backpayTrackedAttributions still reads
block_subscription_attribution and still calls computeSubscriptionShare;
flow C is not superseded and its rate is an open leadership decision. The
Sybil per-app cap keeps its coverage, re-expressed over subscription rows.
- RateCard.spendSharePct and every published card's value. Cards are
immutable snapshots and rows stamp a version; the field is documented as
retired rather than removed.
- the spend attribution write path's shape. block_spend_attribution rows are
still written with spend_share_pct = 0, app_owner_share_cents = 0 and
rate_card_version = 'unrated'; no column is dropped and no migration is
added.
What this costs: 52 status='tracked' spend rows from 9 distinct non-operator
users become permanently unpayable. That is acceptable only because the removed
formula pays $0.00 on every one of them at any rate below 100% — the loss is
nominal, not real.
Also drops the half of block-spend-attribution-status-default.test.ts that
pinned the schema default against the status the SPEND backpay read selected.
That read no longer exists, so the guard failed closed rather than passing
vacuously; the schema-vs-migrations half is unchanged. Comments naming the
removed bounty across blocks.router.ts, buzz-helpers.ts, app-spend-cap.service.ts
and RATE_CARD_V4 are corrected in the same pass.
* docs(blocks): re-point the dev-token spend gate and correct the rail's stale claims
Round-0 audit follow-ups on the spend-bounty removal. Comment-only: `git diff`
over this commit contains zero non-comment changed lines (checked mechanically,
with a positive control — the same filter over the parent commit reports 883).
1. RE-POINT A SECURITY GATE RATHER THAN DROP IT (dev-token.ts, both sites).
The APPID MISATTRIBUTION block ended with "GATE: before #2605 turns on a
non-zero spendSharePct, re-confirm no pending-path mint can ever land a real
OauthClient.id in appId". After the parent commit nothing reads a rate card's
spendSharePct, so that trigger can never occur and the gate was unclosable.
The hazard moved rather than went away, onto two surfaces that are CLOSER
than the dormant ledger they replaced, and the comment now names both:
a live reader today (app-analytics.service.ts aggregates
block_spend_attribution for the app-owner dashboard), and the author fee
(recordSpendAttribution drives observeBlockAuthorFee, #4922 slice 1).
Verified, not assumed: the call path (buzz-attribution.service.ts:19 import,
called at :686, the only non-test caller) and the flag state
(app-blocks-author-fee-enabled — plain global boolean, base enabled: false,
no segment, no rollouts, read from the flag store's authoritative revision).
CORRECTION TO THE AUDIT'S FRAMING: slice 1 is observe-only and carries NO
recipient — it is keyed on baseGenerationBuzz / priceIsCap / generationType
alone — so appId is not load-bearing for it and a forged row cannot misdirect
a fee today, flag on or off. The flag's state is therefore deliberately NOT
the trigger. The re-pointed trigger is the SETTLEMENT slice landing a
recipient derived from this app resolution, and the gate names what
re-confirms it: the existing S1 case in src/tests/api/v1/blocks/dev-token.test.ts.
That assertion is the pin — the gate points at a running test, not at prose.
2. RETIREMENT NOTE AT THE ACTIVE RATE CARD'S DECLARATION SITE (rate-card.ts).
Only V4's doc block and the type-level doc were annotated; RATE_CARD_V5 — the
ACTIVE card — still read "spendSharePct: 5, // Carried from V4 verbatim". An
engineer authoring a V6 edits that line, not the type.
The immutability defence does not bind: recordSpendAttribution hardcodes
rateCardVersion = UNRATED_RATE_CARD_VERSION, so spend rows stamp 'unrated'.
Recorded honestly as "retained without a KNOWN historical referent" rather
than as an absolute, because of one caveat the audit did not have: the
pre-track-only path in #2627 DID stamp share.rateCardVersion, and #2635
retrofitted it the SAME DAY (2026-06-18); that retrofit's migration records
contemporaneously that prod held 0 spend rows. Established from code history,
not re-confirmed against the database. The field is NOT removed. The
type-level doc's "rows stamp a card version" justification is corrected too.
3. THE KEPT MODULE NAMED A DISBURSER THAT DOES NOT EXIST (backpay.service.ts).
It claimed "A SEPARATE payout rail (PR #2605) later disburses confirmed rows".
#2605's full diff aggregates and flips blockBuzzAttribution ONLY — the
PURCHASE table — with zero references to block_subscription_attribution or
block_spend_attribution. The earlier pass checked #2605 against the DELETED
code, correctly, and never against the KEPT code's own claim. No replacement
disburser is invented: confirmed is a terminal state in practice, and whoever
builds one owns re-pointing that paragraph.
Three sibling claims of the same class, found by enumerating every #2605
reference in the tree, corrected the same way: buzz-helpers.ts, blocks.router.ts
and the S1 test's own comment. isPayoutEligibleBuzz is still load-bearing,
just not where those comments said — it narrows the realized debit to its PAID
portion BEFORE that becomes grossValueCents, so free Buzz never enters the
money basis at all, which is a stronger placement than a payout-time gate.
4. STALE BOUNTY COMMENTS the parent commit claimed were "corrected in the same
pass": blocks.router.ts (the author-bounty spend basis note, plus three more
"bounty" phrasings), schema.full.prisma, dev-token.ts, block-workflows.service.ts,
and two in buzz-attribution.service.ts (UNRATED_RATE_CARD_VERSION's
"share-pending" doc, and "awaiting the payout-time backpay" on the spend write).
The audit was half wrong about the Prisma one: it reported both halves false
and the named guard deleted. block-spend-attribution-status-default.test.ts
STILL EXISTS AND PASSES — what was dropped is its second assertion. The
schema-vs-migrations half is real, still asserted, and is now the whole of
what that comment claims.
BOUNDARY RATIONALE VERIFIED by enumerating every non-test reference per table:
flow A has a live independent reader (app-analytics.service.ts:408), so removing
the bounty orphans nothing; flow C has a live writer (stripe.service.ts:1091, the
paid-invoice path) whose only reader is the backpay.
TESTS. Covering set 681/681 passed over 9 files (the parent's 6 plus dev-token,
author-fee and no-divergent-author-fee-base). Prettier clean.
Full unit suite on this host: 42,109 passed / 29 failed / 33 skipped over 1,876
files. 🔴 That is 26 more failures than the parent commit recorded, and NONE are
from this change — established by control, not by argument: all 9 edits were
reverted and the three affected files re-run at pristine HEAD, which reproduced
26 failed / 3 files identically. They are a host artifact (Node v26.8.1 —
localStorage is undefined, so zustand's persist middleware throws
"Cannot read properties of undefined (reading 'setItem')"; plus a @prisma/client
loadLibrary failure), and they fail instantly (8-16ms), which rules out load.
The remaining 3 are the parent's known flakes — 2 in eventloop-watchdog.capture
and 1 in moderation.instrumentation, not 3 in eventloop-watchdog as recorded.
NOT DONE, deliberately: the gate's premise "no production code reads a rate
card's spendSharePct" is true and mechanically checkable, but a no-*.test.ts
guard forces edits to package.json's test:lint-rules plus literal count-and-name
edits in CLAUDE.md and .claude/agents/civitai-test-review.md (pinned by
no-lint-rules-script-drift) — a five-file change about the deletion rather than
about the gate's instruction. Filed in the PR body with a closing condition.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(blocks): upgrade the spendSharePct retirement note to a measured claim
Round-1 audit follow-ups on the spend-bounty removal. Round 1 returned no red
findings and a safe-to-merge verdict; every item below is yellow or green, and
every one is the same class this PR exists to fix — a stale claim about the
removed rail.
NOT comment-only, by exactly one line. `git diff -U0` over this change contains
ONE non-comment changed line: the deleted `expect(mockDbRead).not
.toHaveProperty('blockSpendAttribution')` in backpay.service.test.ts (item 5).
Checked mechanically with a positive control — the same filter over the parent
commit's parent reports 883, independently reproducing the figure that commit
recorded for itself.
1. THE RETIREMENT NOTE IS NOW MEASURED, NOT INFERRED (rate-card.ts, both the
RATE_CARD_V5 note and the type-level doc). It said the field is retained
"without a KNOWN historical referent", established from code history plus a
contemporaneous migration note and explicitly NOT re-confirmed against the
database. It has now been re-confirmed: a GROUP BY rate_card_version over the
whole of block_spend_attribution in production on 2026-09-18 returned ONE row
— 'unrated', count 601. The grouping is self-discriminating, so a second
stamped version would have come back as a second row; none did.
Stated at the scope actually measured: that is a fact about that table at that
moment, not a guarantee about the future. The note says so, and says it holds
only while recordSpendAttribution stays the sole writer hardcoding the
sentinel.
2. THE SCHEMA'S MODEL DOC STILL DECLARED THE TABLE TO BE THE REMOVED RAIL
(schema.full.prisma). The model-level doc for BlockSpendAttribution opened
"W3 flow A — App Blocks buzz SPEND attribution (author bounty)" and carried an
ACCOUNTING paragraph asserting the author share is "a PLATFORM-FUNDED BOUNTY
paid on top of the spend, sized as a percentage of the spend's USD value" —
present tense, for a rail this PR deletes. This PR had already edited that
file (the `status` comment ~100 lines below), so a reader met a freshly
corrected comment under a stale header and concluded the header was current.
Rewritten to lead with TRACK-ONLY, name what the rows actually carry, name the
live consumer (app-analytics.service.ts, reporting — not payout), and keep the
platform-funded accounting only as explicitly historical context for why the
CHECK constraints are asymmetric with the purchase table. Two field docs fixed
with it: rate_card_version (always the sentinel) and spend_share_pct, whose
doc read "the spend rev-share percentage stamped at write time" for a column
the write path hardcodes to 0.
3. THE FUNCTION'S CONTRACT LINE CONTRADICTED ITS OWN DOC BLOCK
(buzz-attribution.service.ts). recordSpendAttribution's JSDoc opened "Record
an author bounty for a block-initiated generation" while line 484 of the same
block, added by this PR, said the percentage author bounty is GONE. The PR
rewrote the body and left the first sentence — the line most readers take as
the contract. Its section banner at :269 said the same thing.
4. THE RE-POINTED GATE COULD BE SATISFIED BY ACCIDENT (author-fee.ts). Round 1
verified the dev-token gate's reasoning and its mechanical closing condition.
The gap was DELIVERY: its trigger is "the settlement PR's author must have
read this block", and nothing on the slice-2 surface pointed back at it —
`git grep 'dev-token' -- src/server/services/blocks/` returns zero hits in
author-fee.ts, and none of its eight `slice 2` markers mention appId or the
app resolution. Slice 2 will be authored here plus a settlement service; its
author has no reason to open a dev-token mint handler.
Added a back-pointer beside the SLICE 1 IS DARK banner, where settlement is
first named. Observability only — the underlying hazard is already closed in
code (the pending path mints pending-pubreq_<ULID>).
5. THE VACUOUS ASSERTION (backpay.service.test.ts). `expect(mockDbRead).not
.toHaveProperty('blockSpendAttribution')` asserted a property of the test's
own hoisted mock literal, declared 200 lines above. It could only fail if
someone edited that literal, so it said nothing about the service while
reading as coverage of it. DELETED rather than restated — the real guard is
the implicit one round 1 mutation-tested and found genuine (the mock exposes
only the subscription delegate, so a reinstated spend read throws on an
undefined delegate, and reaching a resolved summary at all is the proof). The
comment now names that mechanism explicitly, and records why the assertion
went rather than leaving a silent deletion.
6. GREEN: the "accrue the author bounty off the REALIZED debit" note in
blocks.router.ts, twelve lines above a hunk this PR rewrote. The
realized-vs-estimate reasoning survives the removal on its own terms — the row
is the durable money BASIS — so it is re-derived rather than deleted.
RE-SWEEP. The earlier enumeration used grep '#2605' and grep 'bounty' over src/,
which can see neither a .prisma model doc nor anything under packages/. Re-run
over every tracked file for spendSharePct|spend_share_pct|block_spend_attribution
|BlockSpendAttribution|platform-funded|#2605. Beyond the items above it found
three stale present-tense comments in blocks.router.workflow.test.ts ("the PAID
portion must accrue the author bounty", "the author bounty must accrue off what
the user NET paid", "no author bounty accrues") — corrected to name the recorded
money basis, which is what those cases actually pin. Deliberately NOT changed:
the migration .sql files, which are the historical record of what each migration
did when authored; the generated packages/civitai-db-schema/src/{models.ts,
kysely/types.ts}, which mirror types and carry no prose claim about the rail; and
prisma/schema.prisma, which is gitignored and generated.
TESTS, real counts, all green, no regression against the round-1 baselines:
backpay.service 16, rate-card 24, spend-attribution.service 40 (80/80 over 3
files); dev-token 108 + no-divergent-author-fee-base 6 (114/114); the router
workflow suite + block-spend-attribution-status-default (427/427); and the whole
civitai-db-schema package, which owns the prisma-schema parser and the drift
gate, 423/423 over 17 files. Prettier clean on all six TS files (schema.full
.prisma has no prettier parser configured — pre-existing, the parent commit
edited it the same way).
A comment change has NO killing mutant, and none is claimed. The one behavioural
delta is a deleted assertion that was already proven to assert nothing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* chore(db-schema): regenerate kysely types for the BlockSpendAttribution field docs
CI caught a real gap: lint.yml step "Generated DB schema files match their
generator" (pnpm run db:check-generated) failed on the previous commit, which
also skipped "Package unit tests" and therefore tripped the
"Assert every package suite actually ran" ledger. One root cause, two red steps.
FIELD-level /// docs in schema.full.prisma propagate into the generated
packages/civitai-db-schema/src/kysely/types.ts; MODEL-level /// docs do not.
The earlier commits in this PR edited only the model doc, so the gate never
fired and their precedent read as "doc edits here need no regeneration". That
inference was wrong the moment this PR added docs to rate_card_version and
spend_share_pct.
Regenerated with `pnpm run db:generate` inside the flake dev shell (prisma
engines are unavailable to a bare npx on NixOS). Only kysely/types.ts moved;
models.ts and enums.ts are unchanged. The diff is comment-only — the two
propagated doc blocks, no type or field change.
Verified: civitai-db-schema package suite 423/423 over 17 files; prettier clean;
a fresh db:generate now leaves the tree clean, which is exactly what
db:check-generated asserts.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* docs(blocks): genericise the prod measurement and fix five stale bounty claims
Round-2 audit follow-up. Comments and test names only; zero executable delta
(verified by masking comments + it() descriptions and re-diffing HEAD vs index,
with a positive control at
|
||
|
|
22ec53d8dc |
test(blocks): make the seam guard's controls constrain what the ledger consumes (#4934)
* test(blocks): make the seam detector control constrain the predicate the ledger uses The control asserted LOGIC_MODULE_IMPORT directly, so the union in isCallSite could be narrowed to an intersection with every case still green — both real consumers satisfy both halves. Route the synthetic sources through isCallSite via a seeded key deleted in a finally, and add the symbol-only arm so the union is pinned from both sides. Second control for the comment strip, which runs at load and so was observable by nothing: at least one file must name the symbol raw and not after stripping. It pins that the strip is applied, not that it changes a verdict — no file's prose spells a call or an import today, so dropping it is fragility rather than a bypass. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(blocks): close the walk-scope and call-position holes in the seam guard Review found two more mutants that stayed green. Narrowing the walk to src/server keeps every assertion green — `> 500` reads like a scope check and is not one, src/server alone clears it five times over — while a consumer added under components/ or pages/ becomes invisible to the ledger. And the only symbol-half arm was namespace-qualified, so dropping the `(` degraded the detector from "calls it" to "mentions it" with nothing red. Adds the trees the walk must reach, a bare-call arm, a negative that varies only the symbol half, and a throw if a real file ever occupies the synthetic rel — the one path where the seam could evict a consumer instead of reporting one. Drops the parallel RAW map for an on-demand read, matching the two guards that already do this, so nothing has to keep two key sets in step. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(blocks): check the walk's coverage, not its reach Review found the scope assertions this round added were the same category of proxy they replaced: `some(rel => rel.startsWith(tree))` is satisfied by one surviving file, so dropping `x` from the extension filter (1,720 files, all of components/ and pages/) or skipping a single directory (340 under pages/api, where the block REST routes live) stayed green. Enumerate the expected side independently of the walk instead, and name the files any narrowing loses. Also pins which comment kinds the strip removes — the count was satisfiable by a block-comment survivor, so a stripper that stopped handling `//` was green — and adds an explicit-extension import arm. `components/ActionIconInput.tsx` is a directory, so the enumeration filters on dirents rather than on the extension alone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(blocks): stop the guard's two sides sharing a rule, and pin the pre-filter Three green mutants from review, one of them introduced by the previous round. The containment check shared `isTestPath` with the walk it grades, so widening that predicate moved both sides together and hid whole trees — the same loss the round before it closed, relocated into the shared helper. The previous round also REPLACED the per-tree loop rather than adding beside it, and that loop caught this. Spell the test-path rule out again on the expected side, assert set equality rather than containment, floor the expected side, and keep the loop. The raw pre-filter decides what the detector is ever asked about, and `verdictFor` writes past it, so nothing constrained it: dropping its module half made a re-exporting barrel invisible with everything green. Named and pinned. The comment-kind fixtures started at column 0, so a line-start-anchored stripper — the regression that module's own header records — passed them. Indented, plus a trailing-comment arm. `walk` uses dirents rather than statSync per entry: 258 ms vs 1,699 ms over the same 6,506 files, measured, which more than pays for the second enumeration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(blocks): pin every spelling the pre-filter must admit, and the rules it shares The pre-filter control had one module-half fixture, so narrowing the predicate to that exact literal stayed green while a relative-path re-exporting barrel — the likely spelling for one sitting beside the module — never entered the corpus. Four spellings now, matching the discipline the detector control has. Three more, each closing something nothing observed: `isTestPath` was spelled twice and asserted zero times, so this branch's own `(^|/)tests/` anchor fix changed both copies together and the equality was green across it; `toRel` feeds both sides of that equality, so a normalisation that mangled every rel identically was invisible; and every stripper arm was a `not.toContain`, which a stripper returning '' satisfies. Folds the orphaned docblock this branch left above `couldBeCallSite` into it, and takes the review's comment trims. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(blocks): ask the detector about a rel outside the blocks directory `verdictFor` hardcoded one synthetic rel under server/services/blocks/, and every real corpus file lives under server/ too — so scoping `isCallSite` by path prefix stayed green while a consumer added under components/ or pages/ would never join the ledger. The rel is an input to the predicate; now it is varied. Also takes the review's comment fixes: the pre-filter docblock no longer restates the header's overstated absolute, a miscount and a misbound reference are corrected, and the four barrel fixtures no longer claim a discrimination a substring predicate cannot make. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(blocks): pin that 'contests/' is not a test path Both copies of the test-path rule spell the same anchor, so mutating both to a bare `tests/` substring — the natural tidy-up — is green: the equality sees matching sides and the fixtures carry no path that discriminates. Two real route files under pages/moderator/contests/ would leave the corpus. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(blocks): derive the corpus independently, and vary the rel on an axis that discriminates Both rel fixtures added last round contained the token `blocks` — as does every real corpus member — so a substring scope (`/blocks/i`) on either the detector or the corpus loop stayed green. The fixtures now use rels carrying no such token, and neither names a real file, so a future consumer at that path cannot turn the occupancy guard into a fixture-hygiene error in an unrelated test. The corpus loop was reachable by no control at all: `verdictFor` writes past it by construction. Re-derive the expected corpus from the same walk and compare, so any filter the loop grows shows up as a set difference. Adds a fixture for the occupancy throw, which was itself unexercised — and is what catches a `verdictFor` that takes the rel and ignores it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(blocks): keep the pages/ rel axis, which the last round traded away Swapping the `pages/api/v1/blocks/images.ts` fixture for a `server/services/` one fixed the `blocks`-token axis and dropped the tree axis with it: scoping the detector to exclude `pages/` was red before that commit and green after. Both axes are independent and both are needed. Third fixture restored on the new convention — synthetic filename, no token, no real file. Also drops a path round-trip in the corpus derivation and points its comment at the two upstream cases that make sharing both inputs safe, since deleting either would make it vacuous. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
83eee12058 |
Merge pull request #4869 from civitai/yue2-generator
Add YuE2 music generation |
||
|
|
d4dc1058e6 |
fix(notifications): bust the unread-count cache for the rows cleanup deletes (#4937)
* fix(notifications): bust the unread-count cache for rows cleanup deletes cleanupNotifications batch-deleted UserNotification rows without touching the per-user unread-count cache, so a badge kept reporting notifications whose rows were already gone — "Updates 2" over a list saying "All caught up" — until a mark-read or the one-week TTL cleared it. The delete now RETURNs "userId", viewed and busts the count cache for the users whose deleted row was unread. Read rows are skipped: the cached hash only holds unread counts, so deleting a read row cannot make it wrong (~35% of a batch on prod data). bustUser rather than decrementUser so the next count re-derives from the DB instead of drifting. The lag window is flagged before each bust, as markReadImpl does, so a count landing before the replica catches up cannot cache the not-yet-deleted rows for another week. Measured on the prod replica: a 10k-row batch carries ~8.4k distinct users, ~5.5k of them with an unread row, so the bust side costs ~0.55 Redis DELs per deleted row. Busts are deduped within a batch only — not across the sweep, because a user who reads their bell mid-sweep re-populates the cache from rows a later batch then deletes, and a sweep-wide dedupe would skip that second bust and re-create this bug. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(notifications): say what the lag flag actually closes, and count the busts that landed Review findings on the previous commit. The comment claimed the lag flag stops a count from caching the pre-delete number. It does not: a count that has already picked the replica pool is unaffected, and its setUser still lands after the bust. The flag narrows the window to counts that start after it — the same exposure markReadImpl carries — so the comment now says that instead of claiming closure. The sweep log reported users attempted rather than keys dropped, so a redis outage that dropped none would still log a full sweep. It now counts the busts that resolved. Tests: the redis-failure case rejected only bustUser, so the swallow on the lag flag — the call that fails FIRST in an outage, and would otherwise abort the whole sweep — was untested. It now rejects both and asserts the later user is still busted. Added a batch wider than CLEANUP_BUST_CONCURRENCY so the worker pool's cursor takes its second iteration in a test rather than never, and an assertion on the logged bust count. beforeEach resets the mocks instead of clearing them: a mockRejectedValueOnce survives mockClear and would poison the next test's first bust. Also dropped the undated prod measurements from the comments (they belong in the PR body, where they cannot rot silently) and stopped defending resp.rows against an undefined node-pg never returns. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(notifications): say what the sweep's bust count actually counts, and pin the concurrency cap Second review round on the same change. The log field claimed to count keys dropped. It counts DELs redis acknowledged: bustUser discards the reply, redis acks a DEL for an absent key, and most swept users have no cached count at all — so the number is acks, and it is now named bustsAcked and says so. It still does the job it was added for, which is to stop a redis outage reporting a fully-busted sweep. The lag paragraph asserted the primary-read narrowing unconditionally and then mentioned four lines later that the flag no-ops when REPLICATION_LAG_DELAY is unset. Those cannot both describe production: with the tracker disabled the narrowing is zero and the bust is the only thing working. Said so, and pointed at L5 in docs/plans/notifications-review-action-items.md, which owns deciding that value. Tests: added one that counts busts in flight and asserts the peak is exactly CLEANUP_BUST_CONCURRENCY. Nothing else in the file could see the cap — replace the pool with an unbounded Promise.all and every other assertion still passed, while the cap is what stands between one batch and thousands of simultaneous redis round-trips. Sorted the ids in the redis-rejection assertion, which depended on worker continuation order. Corrected the mock comment, which described a protection the bare vi.fn() stubs do not provide. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(notifications): close nine mutants the cleanup suite let through Round 3 asked two lanes for mutants that go GREEN rather than for prose. They produced nine distinct ones and all nine passed the suite as it stood. None of them is a hypothetical: the SQL predicate, the RETURNING list, the dedupe order and the cross-batch accumulation were all unasserted, so the tests verified the bust machinery thoroughly and the delete itself not at all. Closed, each verified by applying the mutant and watching a named assertion fail: - The sweep could delete the NEWEST rows (`"createdAt" >`), or none at all (`LIMIT 0`), and every test passed — the fake pool answers any query with the rows the test programmed. Now pins the predicate and the limit. - `RETURNING "userId", viewed IS FALSE AS viewed` CONTAINS the old toContain string, and inverts the filter so cleanup busts the read users and nobody else — the original bug, restored, green. The guard is anchored now. - Deduping before the unread filter drops any user whose first returned row is read. DELETE ... RETURNING yields rows in physical order, so that was a coin flip per affected user. The fixture now puts the read row first. - `bustsAcked` accumulates across batches; one busting batch could not tell `=` from `+=`. The log assertion now spans two batches. - Flagging `userIds[0]` for the whole batch left every other user without the primary-read narrowing; the ordering test has one user, so it could not see "each user". The wide test now pins the flag's argument per user. - The concurrency cap is per sweep and covers both round-trips. Hoisting the lag flags out of the pool, or dropping the await on the bust pass so batches overlap, both left the DEL half at 25 and passed. The peak test now counts both calls across two batches. - A bust pass skipped after the first full batch passed everything, because no fixture came near CLEANUP_BATCH_SIZE. It is exported for test visibility and there is a full-batch test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(notifications): pin the whole DELETE, not three fragments of it Round 4 produced six more green mutants, three of them created by round 3's own closures — which is the argument for the change this commit makes. The fragment guards were each substring-blind in a different direction. `LIMIT 10000` passed `LIMIT 100000`. `"createdAt" < $1` passed `"createdAt" < $1 AND viewed`, a sweep that deletes only READ rows and busts nobody while reporting a healthy `deleted`. Neither guard saw the table name, so the subquery could be aimed at "Notification" and delete UserNotification rows by id collision. And the limit guard interpolated CLEANUP_BATCH_SIZE, so both sides moved together and the constant could be set to 1 — a sweep that cannot finish inside the client's timeout — with every assertion still agreeing. So the statement is now asserted whole, by equality, with every literal spelled out. That is a golden string rather than a behavioural check, and the comment says so: the fake pool executes no SQL, so this is the only thing between the suite and a cleanup aimed at the wrong rows. Two behavioural closures beside it: - The full-batch fixture now carries CLEANUP_BATCH_SIZE DISTINCT users and asserts the bust count. All-one-user dedupes to a single bust, which let a truncated bust list — `userIds.splice(1000)`, the shape of a plausible per-batch cap — pass while stranding ~88% of a real batch. - A batch wider than the pool with EVERY bust rejecting. The narrow fixtures gave each user its own worker, so no user was ever reached after a failure; a worker that gave up on error retired and the users past the 25th were never attempted. Also pinned: the lag flag repeats across batches for the same user. Its TTL is REPLICATION_LAG_DELAY seconds and a sweep runs for minutes, so a sweep-wide "already flagged" cache would leave every later batch of that user's rows reading the replica. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(notifications): pin two decisions a later reader would correctly undo Both are things a reviewer raised as tempting to "fix", and neither is recoverable from the code. The peak test hardcodes 25 while its title names CLEANUP_BUST_CONCURRENCY, which reads as an inconsistency — but asserting toBe(CLEANUP_BUST_CONCURRENCY) would agree with the source at every width, including no cap at all. Same shape as the LIMIT guard that let a tenfold batch size through: a guard must not read its expected value from the thing under test. And the two fullness tests look redundant. They are not: one covers a gate that stops on a FULL batch, the other a gate that stops on a short one, and deleting either opens its half. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(notifications): let the fake database fail, and tell the two pools apart Round 5 went after surface rather than spelling, and found the seams the SQL assertion cannot reach: the fake pool could not fail, and it could not be told apart from the read pool. - `notifDbWrite()` -> `notifDbRead()` was a one-word green mutation, because the mock returned the SAME object for both. In an environment with NOTIFICATION_DB_REPLICA_URL set, that is every batch failing with "cannot execute DELETE in a read-only transaction" — and it is a silent no-op anywhere without a replica, so it works wherever you would test it and dies where it matters. The mock now gives the read pool its own object, which throws. - The fake could not reject, so the whole query error path was unobserved: swallowing a transient postgres error would end a sweep early, log a healthy `deleted`, and hand the admin endpoint a 200 while rows accumulated nightly. There is now a failure queue and a test that the error comes out. - Only `captured[0]` was ever asserted, so batches 2..N were unconstrained in both statement and cutoff. Now asserted across every call of a 60-batch sweep, which also puts a floor under a "runaway" pass cap — the loop's real guarantee is that it exits on an empty batch, and a cap above 60 is still invisible. - The logger can now reject, pinning the `.catch` on a call made without await: an unhandled rejection there takes the process down AFTER the deletes have happened, so the caller sees a transport failure and retries the whole sweep. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e2916bde43 |
Merge pull request #4911 from civitai/fix/include-license-fees-in-peak-earning
fix(creator-program): include license fees in peak earning calculation |
||
|
|
7f3288dcde |
Merge pull request #4914 from civitai/feat/add-copy-link-and-highlight-support
feat(model-comments): add copy-link and highlight support |
||
|
|
8b1bbb0aa4 | Explain YuE2 score planning controls | ||
|
|
0f86986a95 |
Merge pull request #4921 from civitai/fix/training-studio-tester-feedback
Training Studio: first fix pass over consolidated tester feedback |
||
|
|
059a0f0ee3 |
fix(training-studio): close the review's labeling-race and balance gaps
Five of Copilot's six findings were real:
- A missing/non-numeric balance from getBuzzAccount now returns null from
the embed's getBuzzBalances instead of coercing to 0 — blue:0 asserted
the whole price was non-Blue with certainty, defeating the fail-safe
"up to" confirmation the unknown-balance path exists for.
- sourceLabel keeps the ARRIVAL text untrimmed (zip .txt and reused
captions), as the switch dialog promises.
- Un-captioned reuse items become labelable only once hydration fills
blobUrl — the drain runs again after hydration, restoring the pre-lazy
behavior (failed hydration still degrades to the manual editor).
- A drain result resolving between abort and delivery is dropped, so a
mode switch can't receive an old-mode label into a freshly reset tile.
- An aborted drain's finally no longer clears the replacement drain's
progress counter — slot and labelRun release only under the identity
guard.
The sixth (getBuzzAccount "returns an array") is wrong — the router's
getUserBuzzAccounts reduces rows into a {clientType: balance} record,
which is exactly how useQueryBuzz consumes it (initialData[type]).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DV3Ku4Eu9qTtzd61zt19dZ
|
||
|
|
9ec2a284ca |
docs(track): correct the hasRemixOfId semantics block (#4936)
* docs(track): correct the hasRemixOfId semantics block The paragraph told an analyst three things that are no longer true, and the field it describes is one a roll-up is taken over. `hasRemixOfId` was documented as ungated on prompt similarity. That held between #3871 and |
||
|
|
043468bafa | Match YuE2 to Simple and Custom music modes | ||
|
|
f58826ba36 |
fix(blocks): record a real generationType for App Blocks pass-through step submits (step:<$type>) (#4928)
* fix(blocks): record a real generationType for pass-through step submits
Every App Blocks PASS-THROUGH submit (`kind:'step'` with a bare orchestrator
`$type` instead of a registry id) wrote `block_spend_attribution.generation_type
= NULL`. The pass-through submit handler never passed the field at all, and NULL
is a legitimate value in that column, so nothing surfaced it: an unbackfillable
hole in a fee-bearing column on the one arm whose type set is open by design.
An untyped spend event can never be typed retrospectively, which is why the
column exists ahead of the per-generation-type author fee.
The value recorded is `step:<$type>` — NAMESPACED, not the bare `$type`.
WHY THE NAMESPACE. `textToImage` and `customComfy` are THEMSELVES real keys of
the live orchestrator `WorkflowStepTemplate.discriminator.mapping` (measured
2026-09-17: 50 keys; `imageGen`, `convertImage` and `chatCompletion` are in
there too). A bare `$type` would stamp `{kind:'step', $type:'textToImage'}` as
`generationType: 'textToImage'` — indistinguishable from a genuine
`kind:'textToImage'` submit and priced as one by a fee that keys on the coarse
segment. Under `step:` the coarse key of every pass-through row is `step`, so a
caller cannot reach another fee group. The same reasoning covers a `$type` equal
to a registry id: `step:convert-image` is not `convert-image`.
THE SUBTYPE IS CALLER-INFLUENCED, AND BOUNDED BY SHAPE ONLY. This arm validates
`$type` against nothing but the platform-internal denylist, and the attribution
write is best-effort off an already-billed submit, so the token is the app's. It
is bounded to non-empty, colon-free, length-capped ASCII by one anchored
expression (three properties, one rule — a separate non-empty check would be
unkillable); anything else DEGRADES to the bare `step` rather than throwing. All
50 live `$type` keys match that class, so no legitimate submit loses depth,
while the wire's own `z.string().min(1).max(64)` admits `a:b` and `foo bar` —
the degrade path is reachable from the wire, and a test proves it against the
real schema. There is no vendored `$type` catalog in this repo, so a membership
bound is not available; that is stated in the header rather than papered over.
DESIGN NOTES, all recorded in code:
- `step` joins `BLOCK_WORKFLOW_KIND_GENERATION_TYPES`, which is what puts it
inside the existing registry-collision guard: a step registered as `step`
would make a bare `step` ambiguous, and that is now a red test.
- `blockGenerationSubtypesFor` becomes `blockGenerationSubtypeRule` and returns
an OPEN sentinel for this one key, so the open axis cannot read as a closed
empty set. `isBlockGenerationType` gains exactly one branch on it.
- `BLOCK_GENERATION_TYPES` (the ledger) carries the bare `step` and NO `step:`
value: the arm is infinite, so the bidirectional ledger test now states the
exemption and pins it (no member starts with `step:`, and the resolver
demonstrably emits accepted values the ledger does not contain) rather than
being weakened into vacuity.
- `PASS_THROUGH_TYPE_MAX_CHARS` is exported so the wire cap and this module's
import-light copy of it are pinned equal in both directions.
Red/green matrix: 12 isolated mutants, each killed by named tests, each
restored and the tree checksum-verified. Suites: generation-type 76/76,
blocks.router.workflow 429/429 (426 at base), blocks service+schema
4572/4572 across 187 files, test:lint-rules 593/593 across 42 files (no
ratchet touched). typecheck 0 errors with a positive control proving it can go
red on the changed file. eslint: 21 problems / 3 errors / 18 warnings both
before and after on the router files — all pre-existing, none introduced.
No schema change: the column is already TEXT with no CHECK and has no readers.
* fix(blocks): close the review findings on the pass-through generationType
Five parallel review lanes ran over the previous commit. This is the fix round —
two real defects, four coverage holes the first mutation sweep could not see, and
four stale claims the widening made false.
DEFECTS
1. `composeBlockGenerationType('step', undefined)` minted `step:undefined` into
the fee-bearing column. The guard was `subtype !== null`, so an off-type
caller's `undefined` interpolated as the STRING "undefined" and the shape test
accepted it. Harmless while every subtype axis was a closed set
(`textToImage:undefined` is in none), which is precisely why the open axis made
it reachable. Verified by execution, then fixed to `typeof subtype === 'string'`
and pinned for both arms.
2. The router test's `expect(stamped).not.toBeNull()` — the one assertion written
to name the defect — could not see it. The pre-change code OMITTED the field, so
the value arrived as `undefined`, and `expect(undefined).not.toBeNull()` passes.
Now `not.toBeUndefined()`.
COVERAGE THE FIRST SWEEP MISSED (each mutant now dies with a named test)
- No fixture contained a DIGIT, so narrowing the class to `[A-Za-z._-]` survived a
fully green suite — and the next upstream `$type` carrying one (`model3DPreview`,
`miniMaxMusic3`) would have silently lost its subtype.
- Only the whitespace corner of the class's complement was sampled, so WIDENING it
by any ordinary punctuation mark survived. Now enumerated in both directions over
all 95 printable ASCII: exactly 65 admitted, 30 refused.
- The arm discriminator `step === undefined` could be loosened to `!step`,
`step == null` or `typeof step !== 'string'` with nothing red — each of which
records `step:<$type>` for a body whose contract answer is `null`. Also pins arm
PRECEDENCE (a registry body with a stray `$type` still resolves to its step id)
and an OWN-key `step: undefined`, which a resolver "tidied" to `'step' in body`
would send back to NULL with the router suite still green.
- 🔴 THE SHARPEST ONE: narrowing the WRITE-SIDE re-check from the shape bound to
the LEDGER (`BLOCK_GENERATION_TYPES.includes`) reinstates the original defect —
every pass-through row back to NULL — and no test in the repo saw it, because the
router tests assert what was PASSED and that line decides what is PERSISTED. The
ledger deliberately contains no `step:` value, so it is the refactor the module's
own prose invites. Two tests in `spend-attribution.service.test.ts` now cover it.
CLAIMS THE WIDENING MADE FALSE (a comment is a claim)
- `buzz-attribution.service.ts`: the log field's `// Bounded (registry-derived or
null)` (flagged independently by two lanes), the param doc's "NEVER the
orchestrator's internal `$type`", and the re-check's "the segment after the first
colon in the closed set that key allows".
- `schema.full.prisma`'s column comment — the doc the unbuilt fee's author actually
reads — plus its generated mirror in `packages/civitai-db-schema/src/kysely`
(`db:check-generated` passes; comment-only, no DDL, no migration).
- This module's own justification for the literal copy of the 64-char cap said
`workflow.schema` "pulls in zod and the whole block wire surface". Review MEASURED
that false: every runtime module it imports is already in this module's graph, so
importing it would add exactly one module. Corrected to the narrow true claim,
including that this precedent is weaker than the one it invoked.
- The test file's header claimed all pass-through assertions are red at base; three
of them hold at base too (there, `step` was not a coarse key, so `step:` values
were refused for a different reason). Relabelled as bounds, not regression
coverage. Same for the `SP` fixture's rationale, which claimed a formatter hazard
that does not exist — the real reason is edge-space legibility.
ALSO: the character class is a FOURTH property beyond the three the bound was asked
for, and unlike the cap it can never have a live seam test — so the header now
states that a future upstream `$type` with an out-of-class character would lose
depth with nothing reporting it, and a new test pins the class against the full
50-key snapshot of the live mapping (measured 2026-09-17) so the claim is at least
checkable. `isBlockPassThroughSubtype` is module-private again (it had no consumer),
the coarse key is pinned to the wire `kind` literal by parsing with the constant,
the wire-seam test now resolves `parsed.data` rather than a hand-built twin, and
the `TOTAL AND NON-THROWING` docblock is scoped to JSON-shaped input (an own
accessor on a read property propagates its throw — unreachable through the wire,
and true of the pre-existing reads too).
VERIFICATION: 20 isolated mutants, all killed with named tests, on the shipped
tree; M20 was first rejected for dying of a ReferenceError (32 of 33 red, none of
them mine) and re-run as a compound mutant that carries its own import, after which
exactly one test — the intended one — fails. typecheck 0 errors. eslint: the two
files this round touches carry 2 pre-existing errors, identical before and after.
prettier clean. test:lint-rules 593/593 over 42 files, no ratchet touched.
* fix(blocks): close the DELTA-round findings (comments + two coverage holes)
Round 2 of the audit ladder re-reviewed the fix round. Comment-and-test only —
no behaviour changes.
COVERAGE
- 🔴 The non-ASCII half of the character class was sampled at ONE code point.
The printable-ASCII enumeration sweeps 0x20–0x7E, so widening the class into a
non-Latin RANGE (Cyrillic, CJK) or by DEL survived the whole suite green. New
test with `\u`-escaped fixtures only — a zero-width space, a BOM, an NBSP and
DEL are invisible in a diff and in most greps, and one of them arrived as a
stray NUL the first time it was written, which made the file read as binary.
Verified: the mutant that adds `Ѐ-ӿ` to the class is killed by this
test ALONE — the ASCII sweep cannot see it, which is the point.
- Deleted an assertion that reads as coverage and provides none: the per-character
loop over `refused` is strictly implied by the set equality above it, since
admitted ∪ refused partitions the 95 code points. Same shape as the reversed
`toBe` deleted last round.
- Deleted the router test's `not.toBeUndefined()` for the same reason (implied by
the literal `toBe`), and moved the explanation of why the ORIGINAL
`not.toBeNull()` was blind onto the line that actually carries the claim.
CLAIMS
- 🔴 `buzz-attribution.service.ts`'s log-field comment gave the WRONG REASON. Last
round replaced "registry-derived" with "safe as a log field because the bound
excludes newlines, control bytes, quotes and whitespace" — also false: the sink
builds the line with a single `JSON.stringify` (`@civitai/axiom`'s client, read
to confirm), so the field is an escaped JSON string value and could not break
the line whatever the class admitted. Fixing a false claim with a different
false claim is the failure mode this file's own discipline names; the comment now
says what the bound really buys (bounded width, no unicode/control junk) and
keeps the part that matters — not a metric label, not an object key.
- 🔴 The header said an out-of-class upstream `$type` would lose depth with
"NOTHING REPORTS IT". Overstated: `WHERE generation_type = 'step'` is EXACTLY
that population — `composeBlockGenerationType(BLOCK_PASS_THROUGH_COARSE_TYPE, …)`
is the only producer of a bare `step`, it is called from one place, and the wire
guarantees `$type` is a non-empty string. No alert, one query. This matters
because the PR asks the operator to ratify the character class, and it was
framed against the class on a false premise.
- The test file's "which of these hold at base" list was wrong a SECOND time: it
named three tests when the answer is five (`a body with NEITHER a step id NOR a
string $type` and `a REGISTRY body carrying a stray $type` also hold at base —
the pre-change resolver never read `$type` and had only the registry arm). A
central enumeration that has now been wrong twice is the wrong mechanism, so each
such test is labelled `HOLDS AT BASE` at its own `it(`, the way this file's two
`INVARIANT GUARD` tests already were. Grep the marker for the population.
- "adding `+ ~ % @ …` (or anything else) to the class turns no other test red" —
the parenthetical was false for six characters that ARE pinned elsewhere.
- "in BOTH directions" survived in two docblocks describing the cap seam after the
reversed assertion was deleted; both now say what actually holds (one equality
plus the literal).
- `spend-attribution.service.test.ts` still said the write re-checks "against the
same registry-derived list" — the exact claim fixed one file over, and it
contradicted a comment 16 lines above it that this PR wrote.
DECLINED, with reasons
- Using `BLOCK_PASS_THROUGH_COARSE_TYPE` for the resolver's `kind === 'step'` test.
That comparison means "the wire kind is step", which BOTH arms share; spelling it
with the pass-through COARSE-KEY constant would read as "this is the pass-through
arm" and be wrong. The constant↔wire seam is pinned by parsing instead.
- Pinning the SET of `recordSpendAttribution` call sites so a fifth writer cannot
omit the field. Correct and valuable — and a new convention guard in
`no-*.test.ts`, which this repo requires be wired into `test:lint-rules` plus the
two exact-phrasing counts `no-lint-rules-script-drift` reads. That is its own PR;
it is recorded in the PR body with a closing condition, and with the reviewer's
sharper point that making the field REQUIRED would not close the class (the type
still admits `null`).
VERIFICATION: 21 isolated mutants now, all killed with named tests on the shipped
tree. typecheck 0 errors. eslint 3 errors / 17 warnings over the six files, all
three individually measured pre-existing at base. prettier clean.
* docs(blocks): fix three label inaccuracies found by the round-3 delta audit
Comments only — `git diff HEAD~1 HEAD` with comments stripped is code-identical.
- 🔴 THE `HOLDS AT BASE` POPULATION IS SIX, NOT FIVE, and the unmarked one was the
test the previous commit ADDED. `refuses NON-ASCII and DEL` passes on the
pre-change module for the same reason the other five do: at base `step` was not a
coarse key, so every `step:<anything>` was already refused. Being NEW is not the
same claim as being RED AT BASE, and the previous commit replaced a
twice-wrong central list with "grep HOLDS AT BASE for the population" — which
made that grep wrong on its first outing. Marker added, with the reason.
- The corrected parenthetical in the printable-ASCII test pointed at "the next
test" for the space/tab/newline/slash fixtures, and the same commit INSERTED a
test between them. Now named rather than positional, which is what a pointer in a
file that keeps growing has to be.
- The new DETECTOR paragraph (`WHERE generation_type = 'step'` is exactly the
shape-refused population) was stated unconditionally. It is true today, and it
holds only because every `recordSpendAttribution` writer passes this resolver's
output — a hand-built `'step'` type-checks and the write-side re-check accepts it.
That writer-set property is precisely what the DECLINED call-site ledger test
would pin, so the dependency is now named where the claim is made.
Verified: 3 suites green (544/544), typecheck 0 errors, prettier clean. The audit
lane that raised these confirmed by execution that all nine fixtures of the new
test pass at base and that six isolated non-ASCII/DEL class mutants are each killed
by that test alone — so the marker is a labelling correction, not a coverage one.
* docs(blocks): the collision test is an invariant guard PLUS one red-at-base line
Comment/title only. The closing audit round came back CLEAN and recorded this as
out of its scope — but it is a label my own first commit falsified, so it gets
fixed rather than left because a reviewer scoped it out.
`INVARIANT GUARD (not regression coverage): no registered step id collides with a
kind key` acquired an assertion in this PR — `expect(BLOCK_WORKFLOW_KIND_GENERATION
_TYPES).toContain('step')` — which is RED at base, where that tuple held only
`textToImage` and `customComfy`. So the test's own comment ("it would have passed
before this change too") and the file header's "Two tests are explicitly labelled
INVARIANT GUARD … they would pass on either tree" were both wrong for it. The LOOP
is still a true invariant guard; the added line is regression coverage. Title, body
and header now separate the two, because the halves have different standing.
No behaviour change; 82/82 in the covering suite; prettier clean.
* test(blocks): pin the fee seam, now that the fee exists on main
`main` gained `feat(blocks): compute the per-generation author fee, dark (slice 1
of 3)` (#4922) mid-review — the consumer this PR's namespace decision exists to
protect. Rebased onto it (one textual conflict, in the pass-through
`recordSpendAttribution` object literal: both sides ADD fields, so both were
kept), and then checked the SEMANTIC interaction rather than assuming a clean
rebase meant a clean merge.
WHAT THE MERGED CODE DOES, read rather than guessed: `author-fee.ts`'s
`resolveBlockAuthorFeeParams` bounds its key with `isBlockGenerationType`, tries
the FULL value, falls back to `blockGenerationCoarseType`, and labels its counters
`coarse_type` with the result. So this PR's central claim — a pass-through row must
not decompose to a kind key — is now a claim about a real function, and it was
asserted nowhere. A new CROSS-MODULE SEAM describe asserts it:
- `step:textToImage` resolves under the `step` group and NOT the `textToImage`
one, and the two get different params;
- `step:chat-completion` does not inherit a FEE-FREE entry (the sharper
direction — recorded bare, such a row would pay nothing);
- under the PLATFORM config a pass-through row falls to the default and is
labelled `step`, as does a bare degraded `step`;
- an out-of-shape `step:` value gets `coarseType: null` from the fee too, i.e. no
guessed group.
🔴 SCOPED HONESTLY, because the un-namespaced harm is LATENT TODAY: the fee is dark
and the platform table has ONE entry (`chat-completion` → 0/0), so a bare and a
namespaced value would both land on the default right now. The exposure goes live
the moment any table carries a kind-key or fee-free entry — which slice 3 makes
per-app and author-editable. The fixtures therefore use a config WITH such entries;
a test over the single-entry platform table could not see the hazard at all.
Also: two comments called the fee "(unbuilt)". It is built (dark). Both corrected,
and `blockGenerationCoarseType`'s docblock now records that it HAS a production
caller — which also retires the review finding that nothing routed a future fee
reader through it.
MERGED-TREE VERIFICATION (a clean rebase is not a clean merge):
- blocks service + schema + router suites: 189 files, 5080/5080
- generation-type + author-fee + spend-attribution: 3 files, 186/186
- test:lint-rules: 43 files (the fee added one), 599/599, no ratchet touched
- typecheck 0 errors — ⚠️ after `pnpm run db:generate`. Before regenerating it
reported 10 errors in `user-hub.service.ts`, a file this PR does not touch:
#4927 added a Prisma field and the local generated client predated the rebase.
A stale instrument, not a defect, and worth knowing before someone bisects it.
- db:check-generated passes.
|
||
|
|
29e8ac13fb |
refactor(creator-shop): return named pack detail fields instead of the meta column (#4929)
* refactor(creator-shop): return named pack detail fields instead of the meta column `getPackDetail` feeds a public procedure, so it now names the four meta fields a pack page renders — cover, cover tiles, Blue Buzz opt-in and purchase count — rather than returning the shared meta blob. The pack editor's rejected-vs-archived split moves with it: the endpoint returns `lastReviewWasRejection`, derived server-side through the same `wasLastReviewARejection` helper the rule already had, so the client keeps identical behaviour without deriving it a second time. The source guard that pinned that derivation moves to the new location. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(creator-shop): share the pack display whitelist, scope the review verdict Review follow-ups on the pack detail narrowing: - `packDisplayMeta` moves from `creator-shop.service` into `creator-shop.data` and `getPackDetail` spreads it, so the pack page's meta whitelist has one definition instead of a fourth hand-written copy. The detail response gains `packMemberCount` and defaults `acceptsBlueBuzz`, matching the storefront. - `lastReviewWasRejection` is answered only for a moderator or the lister — the two viewers whose editor asks the question. Everyone else gets no answer rather than a false one. - The response fence in the new test covers the whole response and the members array, not just `meta`, and uses sentinel values that cannot collide with a price or an id. The editor guard pins the operator and matches an optional-chained read. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(creator-shop): one viewer predicate for pack detail, and fence the shared whitelist Second round of review follow-ups: - The pack detail gate and the review-verdict answer were two spellings of one rule. `canReadPrivateState` is now named once, above the gate, and used by both, so a tightening cannot move one and leave the other. Both uses are covered by tests. - `packDisplayMeta` gains its own tests: a behavioural key listing, and a source-level one, because a conditionally spread field that no fixture sets is invisible to the first. The helper now feeds a public response, so a field added there for a storefront card has to be added here too. - The pack detail response fence extends to the members array by key, not only by value, and the verdict test uses a rejecting fixture so a hardcoded `false` cannot satisfy it. The editor guard matches whitespace, which its line length was four characters away from needing. - Dropped an unused member-join mock from the fixture rather than leaving it stating that the members come from a table this path never queries. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(creator-shop): count the whitelist's fields, not just their spellings A key regex over the source can only see the spelling it guesses at, and `{ coverUrl }` shorthand carries no colon — so the fence counts the conditional spreads as well, which fires however a field is written, and the names say which three. Anchored through the `=` so a longer identifier declared above cannot capture the slice. Also: the helper's docblock said one of its four call sites was public; all four are. And two comments claimed more than their tests pin — sharing a predicate is coverage of behaviour, not a guarantee the sharing survives, and the accepting arms of the gate test are what stop its refusals passing for the wrong reason. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(creator-shop): use the canonical db client mock in the pack detail fences The hand-written `vi.mock('~/server/db/client')` tripped the shared-mock ratchet. The codemod refused it because one local fn aliased the reader's and the writer's `userCosmetic.findMany`, which is the aliasing that mock exists to stop: the ownership read here is on the WRITER deliberately, so a reader-only declaration would leave it empty and quote an undiscounted price. Declared against `dbMock` per client instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(creator-shop): make the writer-pool ownership read observable A comment claimed the reader/writer distinction was load-bearing here while no assertion could see it: the ownership fixture only ever answered an empty array, which is also the reader's default, so pointing the read at the replica changed nothing. One test now owns a member and asserts the discounted quote, and the mutation fails it with `expected 0 to be greater than 0`. Two blind spots noted where the next reader meets them: the canonical mock resets once per file, so call counts accumulate across it; and the whitelist fence cannot see a field added by delegating to a nested helper. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
b1a59932b5 |
test(blocks): restore the local non-vacuity check in the seam guard (#4931)
* test(blocks): restore the local non-vacuity check beside the backslash filter Without it the filter is `[].filter()` on an empty walk. The walk control in the neighbouring `it` answers a different question, and a guarantee that lives in another test is not one this assertion can rely on. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(tests): take the comment-review trims on the seam guard Cuts the label the assertion below already states and the clause defending the diff, and fixes the neighbouring count this branch made stale. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5de4ce490d | chore(moderator): release moderator-v0.0.68 moderator-v0.0.68 | ||
|
|
a2eeefb8be | chore(moderator): release moderator-v0.0.67 | ||
|
|
4a2a6e97b0 |
fix(tests): key the block-gated-images seam ledger in POSIX separators (#4930)
* fix(tests): key the block-gated-images seam ledger in POSIX separators relative() answers in the host separator while every ledger literal in the guard is POSIX, so on Windows `rel === DEFINITION`, `toContain(DEFINITION)` and `SOURCE.get(<literal>)` all missed: the walk's own positive control failed and the two real assertions ran on the empty string. Normalise once where the rel is produced. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(blocks): make the seam guard's normalisation assertion falsifiable Three review findings. `split(sep).join('/')` is the identity on Linux, so the assertion pinning it could only ever fail on Windows; `replace` folds on every host and the case now asserts a hardcoded separated input, which reddens on CI too. And the `=== 'hidden'` prohibition read `SOURCE.get(rel) ?? ''`, so a ledger literal that stopped matching a key let the negative half pass free on the empty string while only the positive half reddened. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(blocks): pin the normalisation at its call site, not just the helper Extracting toPosix created a second removable site and the pinning assertion had moved onto the one nobody would delete: dropping the toPosix(...) call inside toRel left it green. Assert the composed toRel over a join()-built path, restore the check over the walk's real keys, and drop the duplicate of the walk control. Also name the consumers the `=== 'hidden'` prohibition iterates — allow-listing the last one left that test executing zero assertions and green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f835d678fd |
feat(blocks): compute the per-generation author fee, dark (slice 1 of 3) (#4922)
* feat(blocks): compute the per-generation author fee, dark
The App Blocks author fee is moving from a platform-funded percentage bounty
to an ADDITIVE, AUTHOR-SET, VIEWER-PAID per-generation fee: the viewer pays it
on top of the base generation cost, the platform takes no cut and funds
nothing.
fee = max(flatBuzz, pctOfBase x base_generation_buzz)
This is slice 1 of 3 — the computation and its configuration. It is DARK: it
moves no money, writes no column and reads no row. Settlement onto the
licensing-fee rail is slice 2; the author-facing config UI and the viewer-facing
disclosure are slice 3.
What lands:
- src/server/services/blocks/author-fee.ts — the platform defaults (1 flat /
5% of base), the ceilings (flat <= 100, pct <= 100%, and NO minimum on
either), the per-generation-type table, the pure computation, and the one
gated entry point.
- app-blocks-author-fee-enabled — a dedicated global flag, fail-closed. The
flag is read FIRST, before the base is inspected and before the telemetry
module is imported, so with it off the computation is unreachable and emits
nothing at all. The flag does not exist yet, so the as-merged behaviour is
fully dark.
- Three counters and seven log fields on the path spend attribution already
uses, so the settlement slice can be sized from real traffic before anyone is
charged. Nothing is persisted.
Decisions worth flagging:
- NO MIGRATION. The defaults apply to every app including the 24 that already
exist, so absence of per-app configuration means the default applies and
there is nothing to back-fill. Per-app storage only becomes necessary when an
author can edit it, which is slice 3; a table nobody can write to yet is pure
cost on a database whose migrations are applied by hand per environment.
- ZERO BASE MINTS NOTHING, as a rule separate from the formula. A plain
max(1, 5% x 0) is 1, so the flat leg would invent a fee for a generation that
cost nothing. Zero-base generations are real (17 of 600 measured events).
- THE BASE IS WorkflowCost.base, NOT .total AND NOT the attribution row's
buzzAmount. `total` already carries the per-resource model licensing fees,
the lineage fee and the viewer's tips; the attribution row's buzzAmount is
the realized paid debit, i.e. the same gross. A percentage of either would
take a cut of another creator's licensing fee and would compound as
fee-charging resources stack. All three are plain positive Buzz integers, so
nothing downstream can tell them apart — which is why the base is hoisted off
the raw orchestrator response at each submit path and pinned by a seam test.
- The block-facing snapshot's cost stays `{ total }` only. Surfacing `base`
there would publish the cost breakdown to every third-party app for a number
no app has asked for.
- SELF-SPEND IS CHARGED, unlike attribution (which voids it) and unlike
computeSpendShare (which zeroes the share). A bounty is the platform paying
an author out of platform money, so self-spend is a wash; this fee is the
viewer paying the author, and an author using their own app is a viewer. At
settlement that becomes a transfer from an account to itself, which slice 2
must decide about explicitly rather than discover from a rejected
transaction. Called out at the call site.
- The fee STACKS on top of the lineage fee and the model licensing fee. Slice 1
computes this app's own entry only; it wires nothing into the orchestrator's
fees[] array.
- Deliberately NOT a rate card. A RateCard splits platform revenue with an
author; this is new money flowing to the author with no platform share.
* fix(blocks): seed the author-fee type table, and correct what slice 1 claims
Round-0 audit findings applied. The largest is that the platform per-type
table shipped EMPTY, so every production lookup fell to the default and the
resolver's type/coarse arms were unreachable outside their own unit tests.
- SEED `chat-completion` -> 0 flat / 0 pct. Justin's motivating example as a
PLATFORM default rather than something each author rediscovers in slice 3:
chat completion is the highest-frequency generation type an app runs, so a
1 Buzz flat floor per turn is a per-message toll, not a fee on a generation.
Seeding also makes the resolver live, which changes what is worth logging.
- REPLACE the "not a rate card" argument, which was FALSE as written. It said
a rate card splits platform revenue while this is new money with no platform
share; RATE_CARD_V4's own accounting note refutes that - its spend bounty is
"a SEPARATE platform expense paid ON TOP ... NOT a slice carved out of the
viewer's money", with no three-way conservation invariant. The conclusion
stands for two other reasons, both verified: a RateCard is an immutable,
platform-wide snapshot stamped on a row at write time while this fee is
per-app, author-set and mutable; and every card field is a percent of USD
CENTS, where the card's per-row cent flooring is exactly what made the
bounty pay $0.00 (at spendSharePct 5, computeSpendShare returns 0 cents for
every generation under 200 Buzz).
- DROP the two dynamic imports. The comment claimed the flag is read "before
the telemetry module is even imported"; that was false - buzz-attribution
statically imports ~/server/prom/client and blocks.router statically imports
app-blocks-flag, so both were already in the module cache and the .catch
fallback was unreachable. Static imports now, claim deleted not reworded.
- TRIM the Axiom fields 7 -> 4, one instrument per property. Kept
authorFeeSkipped (the only instrument for the flag-disabled denominator),
authorFeeBuzz and authorFeeBaseBuzz (the counters are labelled coarse_type
only, so the per-app / per-isSelfSpend cut lives here) and
authorFeeParamsSource (no counter carries it, and it is genuinely variable
now the table is seeded). Dropped authorFeeObserved (derivable),
authorFeeLeg (duplicates the observed counter's outcome label) and
authorFeeParamsClamped - re-derived after the seed and still a compile-time
constant false. A ledger test now fails when the set grows OR shrinks.
- REMOVE the redundant .catch at the call site. observeBlockAuthorFee is total
by contract, so it was unreachable - and had it been reachable it would file
a throw into the flag-disabled population, one of the two denominators the
slice-2 sizing read divides by.
- MOVE the seam guard to src/server/services/__tests__/no-divergent-author-fee-base.test.ts.
It is a source-text structural guard over a call-site population, which is
the class the no-*.test.ts convention-guard directory exists for. Outside it,
no-lint-rules-script-drift could not see it and test:lint-rules did not run
it, so the one genuinely red-at-base guard here only ran in the full suite.
test:lint-rules: 42 files / 593 tests -> 43 / 598.
- FIX the guard's self-contradicting header, which said the base must be
snapshot.cost?.base - the opposite of its own next paragraph and assertions.
BlockWorkflowSnapshot.cost is deliberately { total } only, so the base comes
off the raw submit response.
- RECORD that the max combinator is operator-specified verbatim ("make it a
'largest of flat or percent'"). Round 0 flagged it unattributed only because
it had not been told.
Red-before/green-after, measured by restoring byType to [] and swapping in
origin/main's router (both md5-verified back): 8 failed / 43 passed -> 51
passed. Mutation sweep 13 mutants, 12 killed each by the guard owning the
property with its own message, plus a comment-only negative control that
SURVIVED. No guard was unkillable; none deleted.
* fix(blocks): apply round-1 audit findings to the author fee
Round 1 returned no red findings; every item below is a yellow or green.
Three of them were properties the code HAD and nothing asserted — each was
found by a mutant that survived a fully green suite, so each fix is carried
here by that same mutant now dying, not by a test merely being added.
ONE REAL DEFECT — an inert constant with two spellings of one policy.
BLOCK_AUTHOR_FEE_MAX_PCT_OF_BASE had exactly two references: its declaration
and one test asserting toBe(1). No implementation read it.
clampBlockAuthorFeeParams capped the percentage leg with
BLOCK_AUTHOR_FEE_BASIS_POINTS_SCALE, which was doing double duty as scale
factor AND ceiling. Setting the constant to 0.5 and deleting the one pinning
line left every test green — i.e. the "pct <= 100%" policy had two
independent spellings that agreed only by coincidence, and slice 3 reads this
constant for author-input validation, at which point a UI bound and an
enforced bound can silently disagree.
Fixed by DERIVING: BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS is
Math.round(MAX_PCT_OF_BASE * SCALE), and the clamp enforces that. Guarded by
a test asserting both halves — the RELATIONSHIP (an over-ceiling config must
land at exactly the declared fraction of the base) and the LITERAL (so the
pair cannot drift together). Proven with a mutant that un-derives the bound
and deletes both literal pins, leaving only the relationship to catch it: it
dies, naming the divergence.
THREE UNASSERTED PROPERTIES, each proven by its mutant.
- Self-spend IS charged an author fee. This DIVERGES from attribution two
lines up, where isSelfSpend voids the row and zeroes the share — a bounty is
the platform paying an author out of platform money, while the fee is the
viewer paying the author, and an author using their own app is a viewer. The
divergence was argued in ~15 lines of comment and pinned by nothing: routing
self-spend to a null base survived green. Now asserted end-to-end on the log
line (voided row, isSelfSpend true, fee 32 off a 640 base).
- The fee is observed AFTER the successful write, never before. That ordering
is the only thing stopping a P2002 re-poll from double-counting, and the
sizing number is this slice's entire purpose. Hoisting the observation above
the create survived green. Now asserted via the flag read — the one thing
every path through observeBlockAuthorFee does — as "a duplicate emits no
second observation".
- The flag-off docblock overstated its test. The comment claimed "a gate moved
below the computation would show up right here"; it does not, because
computeBlockAuthorFee is pure and a hoisted copy emits no counter either.
WIDENED rather than narrowed: a new test hands observeBlockAuthorFee an args
object whose generationType and config are GETTERS, and asserts neither is
read with the flag off — any invocation of the computation must read its
inputs, purity or not. The same test carries its own positive control (flag
ON reads both). The old comment is corrected to say exactly what the counter
assertions do and do not pin. Harmless in slice 1; load-bearing in slice 2,
where the gated code moves money.
SMALLER ITEMS.
- The comment justifying the removed .catch called the enclosing catch "loud",
which understates it by three effects: a rejection there also skips the
success Axiom line for a row that WAS written, skips the write counter, and
runs refundAppBountyAccrual against a persisted row (inert only while
appOwnerShareCents is identically 0). The unreachability argument is sound
for today's caller, so the comment is corrected rather than the .catch
reinstated — and it now records that a reinstated catch needs a THIRD skip
reason, never flag-disabled and never base-unavailable, both being live
denominators.
- The test comment claiming "the dynamic imports inside observeBlockAuthorFee
resolve to them" is corrected: both imports are static, and vi.mock hoisting
is what makes the mocks apply. The same claim was already deleted from the
source docblock.
- The basis-point quantization now FLOORS instead of rounding.
Math.round(0.049999 * 10_000) is 500 bp, i.e. a full 5%, against the module's
own promise that "a stated 5% never charges more than 5%". Unreachable in
slice 1 (only 0.05 and 0 exist), reachable the moment authors type numbers.
A naive Math.floor is NOT the fix on its own: measured, it loses a basis
point on 573 of the 10,001 exact basis-point inputs, because 0.0029 * 10_000
lands at 28.999999999999996 and an author typing 0.29% would be charged
0.28%. Normalising to 6 decimal places first makes all 10,001 exact (pinned
by an exhaustive test) while still flooring 0.049999 to 499.
- One spelling of the missing-base skip. The Prometheus outcome label said
base_unavailable while the Axiom field said base-unavailable — two spellings
of one concept across the two instruments a sizing read has to join. Both now
come from one exported constant.
MUTATION MATRIX (both covering suites, 90 tests, at this commit):
M14 self-spend routed to a null base SURVIVED -> KILLED
M15 observation hoisted above the write SURVIVED -> KILLED
M4 computation invoked before the gate SURVIVED -> KILLED
M1 ceiling moved, its one pin deleted SURVIVED -> KILLED
M1' + the relationship assertion alone SURVIVED -> KILLED
M13 comment-only (NEGATIVE CONTROL) SURVIVED (as required)
harness positive control (default flat 1->2) KILLED, 7 tests
Each kill was read from the runner's own per-test counts, not an exit code,
and each killing assertion carries its own message naming the property.
No behaviour change at any reachable slice-1 input: the derived ceiling is
10_000 exactly as before, and the only percentages production can produce are
0.05 and 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(blocks): apply round-2 audit findings to the author fee
Round 2 verified all eleven prior claims and returned no red findings. Three of
the four items here are false sentences in shipped comments — the class this PR
has been fixing for two rounds — and one is a latent code issue.
F1 — THE PR SAID THE FLIPT FLAG DOES NOT EXIST. IT NOW DOES.
app-blocks-author-fee-enabled was created in the flag store at enabled: false
after this branch's last commit, deliberately: round 1 found that an ABSENT key
makes the evaluation throw, bypass its cache and write a console.error on every
App Blocks generation submit, indefinitely.
Verified live before rewriting, rather than taken on trust — in the civitai-app
environment the flag is BOOLEAN_FLAG_TYPE, enabled: false, with empty variants /
rules / rollouts, and a global boolean evaluation returns
enabled:false, reason:DEFAULT_EVALUATION_REASON, segmentKeys:[]. Negative
control: a fabricated key 404s.
Four sites corrected, not three. The brief named author-fee.ts, app-blocks-flag.ts
and the PR body; a fourth operator note in author-fee.ts still read "create
app-blocks-author-fee-enabled", which is the same stale claim in imperative form.
The conclusion survives — as-merged behaviour is dark — but the REASON changes
and the safety property is genuinely weaker than the old wording claimed. An
absent key had to be CREATED by an operator before anyone could enable the fee;
a present base-false flag is one toggle away, with no deploy and no review. So
the comments now say the posture is flag STATE, not structure, and "cannot
regress open" is withdrawn rather than reworded.
F2 — THE LABEL UNIFICATION WAS ONE SITE SHORT, AT THE METRIC'S DECLARATION.
packages/civitai-telemetry/src/client.ts still enumerated base_unavailable
(underscore) while the emitted value is base-unavailable (hyphen). That comment
is the ONLY place in the repo enumerating the label's value set and it sits
directly above blockAuthorFeeObservedCounter, where an operator writing the
slice-2 sizing join looks: they would query outcome="base_unavailable", get an
empty series, and read it as "no generation lacked a base" — exactly the silent
empty join the unification exists to prevent.
The two other base_unavailable occurrences (author-fee.ts and the test) are
historical narration of the form "it used to say X here and Y there" and are
deliberately left alone.
F4 — THE CEILING CONSTANT ROUNDED THE OPPOSITE WAY TO THE QUANTIZATION IT GOVERNS.
BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS derived with Math.round while
toBasisPoints — added in the same commit, a few lines above — deliberately
floors, because in the module's own words "every rounding goes toward the
viewer". Inert today (MAX_PCT_OF_BASE = 1 gives 10000 either way, and a guard
pins it) and live the moment slice 3 or a policy change sets a non-basis-point
ceiling: 0.123456 derives to 1235 bp = 12.35%, a ceiling ABOVE the declared
policy, the one direction the module says it never rounds.
The derivation is now blockAuthorFeeCeilingBasisPoints(maxPctOfBase), which
calls toBasisPoints. It is a named function rather than an expression inlined
into the constant for a specific reason, recorded at the site: at a declared
ceiling of 1 no assertion about the constant can tell floor from round, so
inlining it makes the defect unkillable rather than merely dormant. A comment on
toBasisPoints now names both callers, so changing its direction is visibly a
change to the enforced ceiling too.
Carried by a mutant, not by a test merely being added: reverting the derivation
to Math.round — which is the pre-change code on the same input — fails exactly
one test, with its own message, "the ceiling derivation ROUNDS - it must floor,
like the quantization it governs: expected 1235 to be 1234". The guard also pins
1235 as the mutant's answer so the case cannot quietly stop discriminating, and
asserts the property at four ceilings that each round up.
F3 — A CONTROL MESSAGE NAMED THE WRONG CAUSE ON AN OVER-READ.
The positive control asserted toBe(1) under the message "probe wired to nothing
— flag ON read nothing", which is right only for 0. Round 2 demonstrated it: an
innocuous second read past the gate produced "probe wired to nothing - flag ON
read nothing: expected 2 to be 1", sending a maintainer into the probe when the
real change is downstream of the gate. Split into a toBeGreaterThan(0) control —
the claim the zeros above actually need — and a separate exactness pin naming
the other direction. Verified by planting a second args.generationType read past
the gate: it now fails with "generationType was read more than once past the
gate - a change downstream of the flag, not a broken probe: expected 2 to be 1".
TESTING
Two covering suites: 90 passed (90) at 3a81dc7 -> 91 passed (91) here, one new
test. With the seam guard: 96 passed (96) across 3 files. The telemetry package
suite is 38 passed (38). Prettier clean on all four files, validated against a
deliberately misformatted copy that it correctly rejected.
Mutation sweep, each mutant restored and byte-compared with cmp afterwards:
M1 platform table emptied KILLED 6 failed / 85 passed
M1' ceiling un-derived, pins deleted KILLED 2 failed / 89 passed
M4 resolver exact-match arm deleted KILLED 6 failed / 85 passed
M5 dark gate deleted KILLED 3 failed / 88 passed
M14 label unification reverted KILLED 2 failed / 89 passed
M15 counter outcome label desynced KILLED 1 failed / 90 passed
F4 ceiling reverted to Math.round KILLED 1 failed / 90 passed
M13 negative control, comment-only SURVIVED 91 passed
NC2 negative control, comment-only SURVIVED 91 passed
Two negative controls rather than one, in two different files, so a green sweep
is not a claim about a runner wired to always-kill.
* fix(blocks): wire the fourth submit path, fee-free while its price is a cap
A fourth `recordSpendAttribution` call site landed on `main` mid-review —
`submitPassThroughStepWorkflow` — passing no base. The seam guard caught it and
went red, which is the guard doing its job: nothing in this branch's own commits
broke.
The new site is WIRED rather than exempted (an exemption is what the ledger
exists to make impossible), but it does NOT charge a fee while its price is a
CAP. `WorkflowCost.variable` means the quoted price is a ceiling that settles
lower: the viewer is charged the maximum up front and refunded the difference
once the provider reports the work delivered. A percentage of that is a fee on
money they did not ultimately spend. That path quotes a ceiling and refunds
`ceiling - actual` on settle, so it is the motivating case.
The cap flag is hoisted off the raw orchestrator response and threaded from ALL
FOUR submit paths, not just the new one — the predicate lives in one place
(`observeBlockAuthorFee`), and a cap-priced generation on any path is the same
question.
A cap gets its OWN counted skip reason, `price-is-cap`, checked BEFORE the base
check. It is deliberately not `base-unavailable`: that is the RECOVERABLE blind
spot, one of the two denominators the slice-2 sizing read divides by, and a
cap-priced generation would not have charged even with a base in hand. The value
is enumerated in `packages/civitai-telemetry/src/client.ts`, the only place in
the repo that enumerates that label's value set, in the same hyphenated spelling
the Axiom `authorFeeSkipped` field uses so the two instruments still join.
"No fee on a cap" is the CURRENT answer, made explicit and testable, not settled
policy — slice 2 owns what a cap-priced path should charge, and now has a counted
population to decide it against instead of an unlabelled blind spot.
Seam guard: ledger 3 -> 4, plus a second relationship over the same population
(every call site must thread the cap flag, and it must come from
`submitted.cost.variable`, never from `snapshot` — whose `cost` is `{ total }`
only, so reading one would be `undefined`, i.e. the fail-OPEN direction).
* fix(blocks): apply round-3 audit findings to the author fee
Three green findings from the final audit round.
1. The ceiling constant could be re-inlined undetected. Replacing the
initialiser of BLOCK_AUTHOR_FEE_MAX_PCT_BASIS_POINTS with
Math.round(MAX_PCT_OF_BASE * SCALE) — leaving the named derivation
function in place — SURVIVED the whole suite, because every behavioural
pin agrees while floor and round agree at the shipped policy of 1. The
"DO NOT INLINE IT BACK" comment was the only guard, and prose is not a
guard. Adds a source-text assertion, the same technique the router seam
guard already uses: the constant's initialiser must name
blockAuthorFeeCeilingBasisPoints and must perform no arithmetic of its
own. That mutant now goes red with its own message.
2. The .catch comment in buzz-attribution.service went stale. It said a
reinstated catch would need "a THIRD skip reason … never flag-disabled
and never base-unavailable"; price-is-cap has since landed, so it would
be the FOURTH and the "never" list had a hole exactly where the newest
reason sat. Restated against BlockAuthorFeeSkipReason as a whole, so the
next addition cannot re-stale it.
3. An absent WorkflowCost.variable is treated as a final price, and that is
the fail-open direction — undocumented until now. The field is
?: null | boolean, so === true merges "not a cap" with "the orchestrator
did not say", and a field that stops being sent silently resumes charging
on cap-priced generations. Documented at the txt2img hoist, at the
pass-through hoist, and in the seam guard, which pins that the flag comes
off the right OBJECT and says nothing about it being absent. Behaviour
unchanged: the tri-state policy is slice 2's to decide.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
a01dcd4d14 |
feat(feedback): snapshot the reporter's console and network errors at submit time (#4819)
* feat(feedback): snapshot the reporter's console and network errors at submit time
A moderator opening a report gets a Faro session id and a Grafana deep link, and
the link is suppressed on every row in the queue today: `faroSessionLink()` returns
null past `FARO_LOKI_RETENTION_HOURS` (72 h). An in-page panel that queried Loki for
console/network detail would inherit the same wall. This writes the data into the
report instead, at submit time, where retention cannot reach it.
The reporter's browser already has it. A bounded ring buffer records the last 10
console errors and the last 10 failed requests, redacted and clipped on the way in,
and `handleSubmit` attaches whatever is in it.
Bounds and redaction, all on the capture side because the schema REJECTS rather than
clips: 10 entries each; 300 chars per console line; 300 chars per URL. Console text
goes through the existing Faro `redactText` scrub. Request URLs lose their query
string and fragment outright, and a non-http(s) scheme is refused rather than
stripped. No bodies, no headers, no stack traces, no `console.warn`/`log`.
`fetch` is NOT patched. Network capture is a passive `PerformanceObserver` over
resource timings, so nothing this adds can alter, delay or fail a request. The cost
is named in the module: status-0 failures (offline, DNS, CORS) are indistinguishable
from an opaque cross-origin success and are therefore not recorded, and a browser
without `responseStatus` captures nothing at all.
Two keys are added to `feedbackContextSchema`. That is the load-bearing half: a
`z.object` STRIPS an undeclared key silently, so a capture shipped without the
declaration would submit cleanly and store nothing.
Ships with a disclosure line on both prompts. `context` already carried the page
path, the `/apps` filters including the typed search term, and the session id, none
of it disclosed, while the screenshot was the only opt-in; adding console and network
capture to that payload is what made the gap indefensible. The drawer's old copy
("so console errors come with the report") was also false — `FaroProvider` excludes
the Console instrumentation — and is replaced by one shared constant both surfaces
render.
Moderator side renders both lists as TEXT. No href, no src, no `EdgeImage`; the
existing 26 rows are unaffected, this helps new reports only.
* refactor(moderator): resolve svelte-review findings on the browser-error panel
Findings from the repo's `svelte-review` (correctness / idiom / abstraction) over the
`apps/moderator` half. The producer half under `src/**` is main-app Next code and was
not in that review's scope.
Correctness:
- `isNetworkError` checked three `typeof`s and never the key set, so an entry the
producer later grows — `{url, status, initiatorType, method}` — would pass, the
renderer would draw its fixed three spans, and `method` would appear NOWHERE: not
in the section, and not under "Other context" either, because the key was claimed.
That is the one drift direction the "other" bucket cannot cover, and it is silent
and total. Now an exact-key check, so a drifted entry dumps the whole array
visibly — the same trade `consoleErrors` already takes on element-type drift.
- The status was coloured red unconditionally under a "Failed requests" heading. The
read side deliberately does not re-impose the producer's `400..599` bound, so a row
stored under a future widened bound could carry a status-0 — an opaque cross-origin
SUCCESS as often as a failure. Red is now conditional on a real 4xx/5xx.
- "last N, oldest first" asserted two things this app cannot observe: N is the stored
array's length, not a cap, so a row holding 400 and a row holding 10 both read
"last 10" and the operator could not tell a complete snapshot from a tail. Now
"N captured", and the unverifiable ordering claim is gone.
- Both lists were unbounded inside a component whose sibling dump caps at
`max-h-64 overflow-auto`. They now cap the same way.
- An empty console string is rejected at the schema (`.min(1)`, as `sessionId`
already does) rather than rendered as an empty bordered box.
Abstraction:
- The two sections are extracted to `FeedbackBrowserErrors.svelte`. The seam is not
the line count: the parent's whole `<script>` — clipboard state, the destroy-time
timer, `faroSessionLink`, `reconstructFeedbackUrl` — serves section one only, and
the moved markup reads none of it. Matches this directory's own precedent.
- 🔴 The extraction is only safe because the request-attribute ledger was fixed
FIRST. It counted `href=`/`src=`/`EdgeImage` in one named file, and both surviving
`href`s live in the section that stayed — so moving `{entry.url}`, the one
reporter-chosen string here that looks like it wants to be a link, would have left
that assertion reading 2 and PASSING over unscanned markup. It now sums across an
explicit file list, with the positive control applied per file. Both halves are
mutation-checked: narrowing the list back to one file, and adding an `href` in the
extracted file, each go red.
- `isString` is used at the `images` branch too, which lets its `as string[]` cast go.
Declined, with evidence rather than preference:
- `text-red-400` -> `text-destructive`. `text-destructive` has 0 uses in this app;
`text-red-400` has 9 and is the documented top of the severity ramp in
`$lib/queue-thresholds.ts`. "Reuse the shapes already on the page" wins.
- Wrapping the row in `<Badge>`. Its fixed `h-5` pill does not baseline-align with
the dense mono row beside it.
- A shared snippet for the two near-identical sections, and a handler table for
`splitContext` — a table indexed by JSONB keys fails open on inherited properties,
which this org has already shipped once.
Comments trimmed to the standard's breakage-guard-only rule: the rollout story, the
"separate deployable" restatement and one piece of review idiom are gone.
Verified by SSR-rendering the panel against absent / empty / populated / hostile /
drifted fixtures: hostile markup escapes to text, `javascript:alert(1)` renders
inside a span rather than an href, and a drifted entry shows every field in the dump.
* fix(feedback): drop the false disclosure, mark clipped strings, collapse console repeats
Three round-0 audit decisions on the browser-error snapshot.
1. DROP the telemetry disclosure line entirely.
It claimed "Web addresses are stored without their query strings", which is
true for networkErrors[].url (sanitizeNetworkUrl clears url.search) and for
context.path, and FALSE for a URL embedded in consoleErrors text:
sanitizeConsoleMessage -> redactText only rewrites params whose NAME is in
SENSITIVE_PARAM_KEYS, so a ?query= or ?prompt= survives verbatim. Console
text stays exactly as the browser emitted it; the sentence goes rather than
being narrowed, so no weaker claim replaces it.
2. Mark a clipped string so a moderator can tell truncation from completeness.
redacted.slice(0, 300) left a cut React hydration message and a complete
300-character one rendering identically. A single U+2026 is now appended
only when slice actually cut, and is SPENT OUT OF the bound rather than
added to it -- feedbackContextSchema REJECTS an over-long value, so a
301-character result would fail the reporter's whole submission. A string of
exactly the max is not truncated and gets no marker.
3. Collapse console repeats into a per-entry count, keeping 10 DISTINCT.
The ring buffer kept the LAST 10, so a React cascade (error -> component
stack -> boundary re-render -> retry) shipped ten copies of the downstream
symptom and zero copies of the originating error. CountingBuffer keeps up to
10 distinct messages, first-seen first, and a repeat increments count on the
entry already there WITHOUT refreshing its position -- refreshing recency
would let a looping message evict the originating error by another route.
The bound stays at 10 distinct: context is a JSONB column fed by a
client-controlled array, and the payload derivation stays ~6.6 KB.
count is a DECLARED field of the nested z.object. A z.object strips at every
level, so an undeclared count would be dropped silently, the producer would
count correctly, submit cleanly, and every entry would render as a single
occurrence. The schema test reads count back out with an unknown SIBLING
field alongside as the negative control.
The moderator renderer shows the count as a badge above 1, splitContext
gains an exact-key isConsoleError guard mirroring isNetworkError, and the
RingBuffer docstring's old rationale ("the first few are usually page-load
noise") is rewritten -- it is right for a slow drift and inverted for a
cascade.
Gates, each with a live control in the same invocation: typecheck 0 errors
(control: TS2339); svelte-check 0 errors over 9489 files (control: 1 error);
apps/moderator vite build ok; prettier clean (control: a real misformat
flagged); eslint clean (controls: prefer-const on .ts, no-debugger on .svelte
-- prefer-const is OFF for .svelte and no-debugger is not configured at the
repo root, so each tier needs its own live rule).
Suites before -> after: unit 96 -> 109, app:moderator 103 -> 114, component
50 -> 46 (the four removed disclosure assertions).
13 mutants, all killed by the test that owns the guard, including: an
unconditional marker and a < / <= boundary slip (both caught by the
exactly-300 arm), a marker appended past the bound, no collapse, a repeat
refreshing recency, eviction leaving the index entry behind, read() handing
back live references, count undeclared, the count floor removed, the
exact-key check dropped, and the badge rendered unconditionally.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(feedback): pin that the repeat index tolerates Object prototype keys
The CountingBuffer added in the previous commit indexes repeats by the console
message itself, which is untrusted text. As a `Map` that is safe; as a plain
object it FAILS OPEN -- `index['__proto__']`, `'constructor'` and `'toString'`
all return an inherited truthy value, so `push` takes the "already seen"
branch, increments `count` on something that is not an entry, and the message
is stored NOWHERE while the first sighting silently vanishes.
Not exotic input: `console.error(someObj)` formats to JSON and library errors
mention these names routinely.
Mutation-checked rather than assumed -- swapping the `Map` for a
`Record<string, FeedbackConsoleError>` reddens this test specifically:
× records a message that collides with an Object prototype key
AssertionError: expected [] to deeply equal [ ...(4) ]
i.e. all four messages disappear entirely, which is the fails-open behaviour
the `Map` prevents. The test pins the data structure by BEHAVIOUR rather than
by grepping the source for the word `Map`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(feedback): clip on code points, install the recorder at import, document the console/network asymmetry
Three findings from an adversarial audit of the browser-error snapshot.
1. clip() could split a UTF-16 surrogate pair.
'length' and 'slice' count code units, so a 300-unit bound landing between the
halves of an astral character kept a LONE SURROGATE. The value still satisfied
feedbackContextSchema's .max(300) (same units), JSON.stringify preserved it as
an unpaired escape across the wire, and Feedback.context is jsonb — measured
against a real Postgres engine, the insert fails with "invalid input syntax for
type json" while the pair-intact twin inserts cleanly. The reporter's WHOLE
submission was lost, with an error naming nothing about the snapshot.
clip now drops the whole character when the cut would split it, so a clipped
result is occasionally one character shorter than max and never longer.
2. The recorder installed too late to catch the case it exists for.
'useEffect(() => installBrowserErrorLog(), [])' runs in the passive-effect flush
AFTER the commit that hydrates the tree, so every console.error React emits while
hydrating — a hydration mismatch above all, which both docstrings name as the
motivating case — arrived before the wrapper existed. The network half is
retroactive by construction (observe({ buffered: true }) replays the
resource-timing buffer); console.error leaves no such buffer, so installing
earlier is the only available fix.
BrowserErrorRecorder is replaced by a side-effect module,
src/utils/feedback/startBrowserErrorLog.ts, imported first in _app.tsx next to
the existing disable-router-prefetch side-effect import. installBrowserErrorLog
already returns a no-op without a window, so the call is inert under SSR — now
pinned by a node-environment test, because module scope is a code path the
effect version never reached.
3. Documented, without changing, the console/network query-string asymmetry.
sanitizeNetworkUrl strips a captured request's query string outright; a URL
inside console TEXT keeps its query string, and only params whose name matches
SENSITIVE_PARAM_KEYS are redacted. That is an operator decision (2026-09-14),
and it was recorded nowhere but a commit message, so the next reader assumed the
two paths were symmetric. Stated on the capture module and on the schema field.
Coverage, each mutation-tested:
· clip: guard disabled -> "does not cut a surrogate pair in half when it clips"
fails on expect(out.isWellFormed()).toBe(true). Range widened to include low
surrogates (over-trim) -> the boundary control "keeps an astral character that
ends exactly on the boundary" fails on its toBe, and nothing else does.
· install timing: module-scope call removed -> "importing the module is what
installs the console patch" fails on
expect(capturedAtImport).toContain(SENTINEL) with an empty buffer.
· SSR guard: 'typeof window' check removed -> the dynamic import in the node
test rejects with ReferenceError and the assertion reports it.
* fix(feedback): drop input-borne lone surrogates, not just ones clip makes
Round 2 of the audit found the other half of the hazard clip guards. clip can
no longer manufacture a lone surrogate, but it never repaired one it was
handed, and an input-borne one reached the wire through both branches --
including the early return, where the value is short enough that clip does
nothing at all. The consequence is the one clip's docblock already measures:
Postgres rejects the jsonb insert and the reporter's whole submission is lost.
Deliberately NOT toWellFormed() and NOT a lookbehind regex. This runs inside
the console.error wrapper, which must never throw, and the repo declares no
browserslist -- both constructs are Safari 16.4+, so a browser below that
would throw a TypeError here. The manual scan is ES5 and cannot.
Mutation-verified: removing the pass fails both new tests on their own
isWellFormed assertions; dropping the pairing branch (over-drop) fails the
new well-formed-astral control plus the two existing boundary tests.
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
8d1c6db53b |
feat(hubs): let a tag source require several tags at once (#4927)
* feat(hubs): let a tag source require several tags at once A hub's tag sources are ORed, so there was no way to ask for images carrying BOTH of two tags. A tag card now holds a set: tags sharing a `groupKey` are ANDed, and the groups are still ORed against every other source. A single-tag source is a group of one, so every hub that exists keeps the filter it had. Grouping means opposite things on the two sides, and the wording is the only place that says so: grouping tags you want NARROWS the feed, while grouping tags you want gone REMOVES LESS, because `NOT (x AND y)` keeps an image carrying only x. The migration is additive and nullable — it needs applying by hand. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(hubs): make a session toggle drop a whole tag group, and bound groupKey Review found that subtracting ONE member of an AND-group turns `A AND B` into `A`, which matches a superset — so a viewer-supplied session toggle could widen someone else's hub past what its owner set. That breaks the invariant the comment above `excludedSources` relies on to justify honouring the list at all. A toggle landing on any member now takes the group with it, which is also what the editor's own switch does. Also from review: - `groupKey` is bounded, so an out-of-range value is a validation error rather than a Postgres INTEGER failure inside the replace transaction. Client-side key minting hands out the lowest free key so it cannot climb past that bound. - `groupTagIds` keys on `exclude` as well as `groupKey`, so folding its two calls into one cannot merge a kept-out tag into a hub's own AND-set. It was relying on being called once per polarity; the client already did both. - `renderHubArm` throws on an empty arm instead of emitting the literal text `undefined` into the filter, which Meilisearch rejects as a 503 far from the mistake. No producer can reach it today. - The group state transitions move into `hub.utils.ts` as pure functions and get tests, including the one that gave a tag added to a KEPT-OUT group the include side — a click meaning "block more" that surfaced content instead. - `sameTarget` is gone in favour of `hubSourceKey`, which the server subtracts session toggles with. - A tag the hub already holds on the same side can now JOIN a group. Refusing it left an owner unable to group two tags they already had: the picker showed the second greyed out as "Added", with no way forward. - `buildDuplicateHubInput` carrying `groupKey` is asserted; dropping it silently un-grouped every copied AND-set. - The resolver is pinned over rows carrying `groupKey: null`, which is what a row written before the column looks like. The compatibility claim was previously asserted at both ends of rows -> resolve -> filter and nowhere in the middle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(hubs): assert the polarity scoping on groupTagIds directly The case routed through `resolveHubSources` passed with and without `exclude` in the map key, because that function already splits its rows by polarity and calls `groupTagIds` once per side. Measured: removing the polarity from the key left the whole resolver suite green. The assertion read as coverage of a guard it could not see. Calling `groupTagIds` with a mixed list is the only way to reach it. With the key mutated it now prints `expected [ [ 77, 90, 78, 91 ] ] to deeply equal [ [ 77, 78 ], [ 90, 91 ] ]`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(hubs): ungroup a tag instead of deleting it, and share one group key Round two of review over the fix round. The one that would have cost a user something: the ✕ on a tag chip is labelled "out of this group" and deleted the source from the hub. That was harmless while every grouped tag had just been added by the same click — but a tag the hub already holds can now be moved into a group, so that ✕ is the only visible way to undo the move, and it destroyed a source the owner had all along. It now clears `groupKey`; the card's trash is what deletes. The rest: - `hubTagGroupKey` lives beside `hubSourceKey` in the schema, and the three places that built that key by hand now share it. The previous round left a third spelling — `toggledOffGroups` keyed on the bare int, correct only because the list it reads was pre-filtered by polarity, which is the exact reasoning the same file's new comment says must not be relied on. - `addTagToHubGroup` refuses a cross-polarity move itself. The caller refuses it too and shows the message, but the caller is component code with no suite, so the tested half now lives where a test can reach it. Without it the move keeps `exclude: true` while taking an include group's key, merging a kept-out tag into an unrelated exclusion AND-set — and `NOT (a AND b)` excludes less than `NOT a OR NOT b`, so an exclusion stops excluding. - `groupMemberKeys` and `findHubSource` are exported and shared rather than restated in the editor. A caller and a helper that disagree about whether a row is held would mutate one the caller believed it had refused. - The comment claiming the client and server grouping "cannot share code" said something a reader can disprove. The real barrier is that `hub.utils.ts` imports `trpc` and so is client-only. - A 🔴 comment on a resolver test claimed it guarded the polarity scoping. It cannot see it — `resolveHubSources` splits by polarity before calling `groupTagIds`. Rewritten to say what it pins. - `groupRule` moved out of `HubSourceCard` so its copy pin does not drag a Mantine component's import graph into a pure unit test. - New coverage: the cross-polarity refusal, the whole moved row (against a fixture that differs from the group on every field a broken move could clobber), the vacated group after a move, a non-tag row carrying a groupKey, and the schema bound. - The mixed-`enabled` group hazard is recorded at the resolver: nothing constrains a group's members to share `enabled`, so a half-enabled group resolves to an AND-set of only the enabled half. Benign only while the owner is the only one who can write that column. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(hubs): tighten the tag chip, and put the chip's X back to removing All from Justin looking at it on a dev server. - The ✕ on a chip removes the tag from the hub again. It had been changed to ungroup, on a review finding that deleting there is a trap now that a tag the hub already holds can be pulled into a group — the ✕ is then the only visible undo. He called ungrouping weird on sight: a ✕ reads as "get rid of this", and leaving the tag behind as a new card does not look like a removal. The label matches, and the tradeoff is recorded where the next reader will find it. - The grey block behind the ✕ during a save was Mantine's `disabled` styling on the ActionIcon. The click is guarded in the handler instead. - The chip's spacing matches the removable-tag chip in `Tags/TagsInput.tsx`. The gap left of the ✕ is Mantine's own right-section margin, which outranks a utility class, so it is overridden through `styles` along with the padding. - Include groups no longer carry a rule line — a row of chips reads as "all of these" unaided. The EXCLUDE line stays: that side removes LESS when grouped, because `NOT (x AND y)` keeps an image carrying only x, and this is the only place in the product that says so. The migration file's own comment repeated the deploy-ordering claim I had to correct in the PR body, so it is corrected here too — with the dev failure that proved it. That comment is the artefact a deployer actually opens. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(hubs): correct a comment that claimed coverage the fixture lacks The move test said its fixture "differs from the group on every field a broken move could clobber". It does not differ on `exclude` — and cannot, because the move is refused outright when the polarities differ, so by the time that branch runs the two are already equal. Measured rather than reasoned: adding `exclude: !!first.exclude` to the move branch leaves the file green. The mutation is a no-op while the refusal stands, which is the honest thing for the comment to say, rather than a claim a reader would take as coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(hubs): pin the copy that actually renders, not a dead branch Review caught that cutting the include-side rule line left `groupRule(false)` with no production caller, while its test and its doc comment both still claimed "this wording is the only place in the product that states it". The product stated one side. A test guarding a string nothing renders reads as coverage and is not. The asymmetry does still ship on both sides — in the `+` tooltip — so that pair is what gets pinned, as `groupAddHint`, and the exclude-only rule line becomes a plain `excludeGroupRule` constant with no include counterpart to be tempted by. Two smaller corrections from the same pass: - The MOVES comment said a moved row "keeps the first three" of the fields its fixture varies. It keeps two — `enabled` is the one it takes from the group, which the assertion one line below says. - The cross-polarity refusal asserted `toEqual(value)` against the same array the refusal path returns, so it compared a value with itself. It now asserts the rows, which survives a future edit that mutates in place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
774fce94cf |
refactor(application-error): same-origin beacon guard on the client-error report endpoint (#4915)
* feat(application-error): per-IP rate limit on the client-error report endpoint
`POST /api/application-error` is wrapped in `PublicEndpoint`, which applies no
limiter of its own, so any unauthenticated caller could post to it without
bound. What the endpoint produces is not a response but an operational signal —
its accepted volume is what tells us the front end is broken — so a single
unbounded source can manufacture that signal.
Adds `checkApplicationErrorRateLimit`: a fixed-window per-IP counter on
`sysRedis`, in the same `SET NX EX` + `INCR` MULTI shape as the sibling limiters
already in `src/server/utils/`. Past the ceiling the endpoint answers 429 with
`Retry-After`, ahead of the session read, the body parse and the sourcemap
resolution — so a limited request sheds the work rather than only changing a
status code.
THE LIMIT: 30 reports / 60s = 0.5/s sustained per address, sized from the
endpoint's own traffic at both ends of the tension, because either end silently
destroys something.
Too loose buys nothing: the ceiling is an order of magnitude below the
aggregate rate that distinguishes a broad breakage from one unhappy user, so
no single address can reach that rate alone — it takes eleven or more distinct
addresses saturating their budgets, which is the many-users condition the
signal is supposed to mean.
Too tight deletes the reports we exist to collect: 0.5/s per address is above
the busiest single sample ever observed for the WHOLE endpoint across every
client (~0.4/s, against a ~0.009/s steady state). One user in a client-side
render loop is still reported in full — the first 30 reports of any burst
always pass, far more than the one needed to diagnose a repeating stack.
KEYED ON `getTrustedClientIp`, not `resolveClientIp`. The attribution predicate
reads `cf-connecting-ip` with no corroboration, so a caller would be choosing
its own bucket and could rotate away from it at will, which voids the only
property this control has. This is the same rule the per-IP limiter on
`/api/v1/block-tokens` already applies. The module is added to the derivation
ledger in `client-ip-ledger.test.ts` under Enforcement, with the divergence from
the two attribution limiters stated there.
FAILS OPEN on a limiter error, and logs when it does. Failing closed would
delete the front-end-break signal for the duration of an infrastructure
incident, which is exactly when breakage is most likely and least visible. No
in-process fallback: a per-pod counter cannot bound a fleet-wide rate, so it
would carry the complexity of enforcement without the property that makes
enforcement worth anything.
UNCHANGED: the request schema, the `resolveStack` handling, and the absent-name
default — the last now pinned by a test, since it sits directly behind the new
early return.
TESTS. A limiter unit suite backed by a REAL in-memory counter keyed on the key
the module actually builds, so "the budget is per address" is a property of the
code rather than of a scripted stub. An endpoint suite driving the real handler
through its real wrapper, covering 429, `Retry-After`, per-IP independence, that
the shed work really is shed, and fail-open. And the client-side fire-and-forget
contract: a 429 resolves rather than rejecting, so it cannot affect a render —
plus a ledger asserting the reporting helper is the only module in `src/**` that
fetches this endpoint, so that contract covers the whole population.
Every guard was watched to fail: thirteen mutations, each killed by a named test
with its own assertion message, plus a whole-file inert control.
* refactor(application-error): replace the per-IP limiter with the same-origin beacon guard
Pivots this PR off the bespoke per-IP redis rate limiter and onto the guard the
sibling telemetry beacons already use. The limiter is deleted, not disabled.
WHY THE LIMITER WENT. Its key collapsed to a shared upstream address whenever the
corroborating edge header was absent, so a whole population shared one budget and
the accepted volume was capped BELOW the level that distinguishes a broad breakage
from one unhappy user. That is silent in the wrong direction: it suppressed the
operational signal precisely during the incident the signal exists to surface. It
also penalised every shared-egress population — corporate NAT, mobile CGNAT, VPN
and privacy-relay exits, whose addresses are exactly the ones the edge reports —
and it bounded a population wider than the signal actually reads.
WHAT REPLACED IT. `isSameOriginBeacon`, lifted verbatim from the three beacons
that already ship it (`src/pages/api/internal/pulse.ts`,
`src/pages/api/track/block-render.ts`, `src/pages/api/track/batch.ts`), whose own
comments call it "the established beacon pattern": compare the host of `Origin`
— falling back to `Referer` for clients that suppress it — against the request's
own `Host`, and reject anything else with 400. All three open-code the block, so
this extracts it to `src/server/utils/beacon-same-origin.ts` and uses it here
rather than making a fourth copy. The three existing call sites are deliberately
NOT rewritten in this commit: that is a behaviour-preserving change to the busiest
endpoint in the app and belongs in its own reviewable diff.
IT CANNOT REJECT OUR OWN PAGES, and that is the property that mattered most,
because a false reject deletes the signal silently — `fetch` does not reject on a
4xx and the reporting helper terminates its promise with `.catch`, so a wrongly
rejected report is indistinguishable from no error having happened.
Structurally: the reporter POSTs a RELATIVE path, so the browser's `Origin` and
the `Host` the request is routed to are the same string by construction, on any
host the app serves. The guard enumerates no domain and therefore cannot single
one out — adding, renaming or previewing a served host changes nothing.
Empirically: the three siblings have run this exact rule in production for a
long time at high volume. Measured over a 6h window, POSTs answered 400 were
0.111% on one beacon and 0.029% on another, against 200s numbering in the
hundreds of thousands. Those 400s also include the body-parse and schema
branches, so the guard's own share is at most that. A served host or browser
family being rejected wholesale would have to account for under 0.111% of that
beacon's traffic to hide inside the figure.
THE DEV BRANCH IS SCOPED TO THE GUARD, not to the whole handler as in the
siblings. Theirs returns 200 before their analytics write, which costs them
nothing; here it would also skip the schema parse, so a malformed body would
answer 200 in dev and 400 in production. The observable dev property is the same
either way — the guard rejects nothing locally.
DELETED, with nothing left dangling: the limiter module and its unit suite, the
`REDIS_SYS_KEYS.CLIENT_ERROR` registry entry, and both rows the limiter added to
the client-IP derivation ledger (it no longer derives a client IP). Also the
`describe` block asserting that a 429 resolves rather than throwing: its fixture
installed a `fetch` double that resolves by construction, so the property was
supplied by the fixture rather than by the code. The ledger half of that file —
which pins that the reporting helper is the only module fetching this endpoint,
with its own positive and negative controls — is kept.
TESTS. A unit suite for the predicate, led by the accept cases because those are
the load-bearing half, including one that pins the no-enumeration property across
six unrelated hosts. An endpoint suite driving the real handler through its real
wrapper for the seam the unit suite cannot see: that the handler calls the guard,
answers 400, sheds the session read and the sourcemap resolution behind it, and is
inert in dev. Eleven mutations were each watched to fail against a named test,
plus a whole-file inert control.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* test(application-error): restore the network-rejection case the prune took with it
The previous commit deleted a `describe` block whose cases asserted a property
their own fixture supplied — a `fetch` double that RESOLVES by construction, so
"a 429 resolves rather than throwing" only ever proved that a resolving mock
resolves. That reasoning was right, and those cases are staying gone.
The deletion was one case too wide. Inside that block sat a case on a REJECTING
`fetch`, and it is not the same shape: its fixture supplies a rejection, so the
fulfilment it asserts cannot come from the fixture. It can only come from the
terminating `.catch(() => undefined)` in `reportApplicationError`, which is the
reason every caller — a `catch` block or a React error boundary — can call the
helper without handling anything. Nothing else in the tree covered that path
after the prune.
Recovered verbatim from
|
||
|
|
3a60f1151c |
App Blocks: denylist-only pass-through arm on the orchestrator bridge (#4909)
* feat(app-blocks): denylist-only pass-through arm on the orchestrator bridge
Adds a second arm to the `kind: 'step'` wire member: `{ kind:'step', $type,
input, maxBuzz }`. An app names an orchestrator step type directly and its
native `input` is forwarded unmodified; the only thing between the app and the
orchestrator is `PLATFORM_INTERNAL_STEP_TYPES`.
Before this, `blockStepBodySchema` gated `step` against `REGISTERED_STEP_IDS`
(two entries: convert-image, chat-completion) against ~50 live orchestrator
step types, and `assertStepTypeAllowed` ran inside `buildBlockStep` on a type a
registry entry had already declared — so the denylist only ever saw an
already-closed set.
Wire shape. The two arms share the `kind` discriminant, so the member is a
nested discriminated union on `step` itself: the registry id on one arm,
`z.undefined()` on the other. Three alternatives were run against zod 4.0.17
rather than reasoned about, and the two obvious ones both fail — see the
comment above `blockWorkflowBodySchema`. `textToImage` and `customComfy` are
untouched.
Spend. Reuses the inline-comfy mechanism rather than inventing a third: the app
declares one `maxBuzz`, the server derives `stepTimeoutSeconds = maxBuzz` and
stamps it as the step timeout (the physical per-job Buzz cap), reserves
`max(maxBuzz, whatIf quote)` on the per-user, per-app, consent and dev-session
counters, and settles to actual on the terminal poll through the existing
`persistCustomComfySettle` record. An unquotable `$type` reserves the declared
ceiling instead of refusing.
Outputs. Image blobs are lifted out of the forwarded output and pushed onto
`imageUrls` and `AppWorkflow.images` -- the one channel the publish path and
the per-viewer gated read already own -- so a pass-through image cannot reach a
viewer around `getBlockGatedImagesByIds`. The rest of the output is forwarded
verbatim on a new optional `stepOutputs` snapshot field.
No prompt audit, no AIR scan and no entitlement check on this arm, by explicit
operator decision. See the PR body for the two consequences worth a reviewer's
attention.
* fix(app-blocks): review round on the pass-through arm
Five review lanes over the first commit. Four substantive changes, two
corrections of claims the code made about itself, and one finding kept as a
flag rather than a fix.
Blob extraction is a SHAPE test, not a key list. The first draft enumerated
four property names; measured against the live orchestrator spec those cover 4
of the 19 names that carry a blob across the reachable types. `video`,
`audioBlob`, `svg`, `frames`, `tempBlobs`, `draftCache`, `additionalVideos` and
seven on polyGen alone would each have ridden out as a raw url inside the
forwarded output -- the second image channel the splitter exists to prevent, on
~40% of the media-producing set. It now tests the orchestrator `Blob` shape,
plus a top-level case for types like `transcode` whose whole output is a blob.
Depth 1 is a stated and tested limit.
Extraction is gated on the server-stamped step name, not on "the $type is
unrecognised". Those are different sets: the second forwards the output of any
step the ORCHESTRATOR put on a workflow, on textToImage and customComfy
workflows too. With the name gate the pre-existing "still skips an
unregistered, non-native $type" case is restored verbatim rather than inverted,
and a sibling pins the other side.
The orchestrator tag slot takes a constant. The first draft passed the
app-supplied `$type` into the array that also carries `app-block:<appId>` -- the
tag the app-scoping guards read -- and defended it with an unmeasured claim
about orchestrator rejection.
The gated-image test was reconnected: it hand-built an Image row and would have
passed with the whole feature deleted. Its url now comes from the projection.
Corrections to claims the code made about itself:
- "a pass-through chatCompletion returns its prose unscanned" was FALSE. All
three consumers look the registry entry up by $type before the pass-through
branch, and chat-completion declares `chatCompletion`, so that case IS
scanned. The real hazard is the shadowing flip, which is what the comments
now describe.
- the builder's docstring claimed the suite pins `input` "by reference
identity, so a spread cannot pass". It does not and cannot -- a shallow
spread is observationally identical and the mutant survives the whole suite.
Recorded in place.
- PASS_THROUGH_MAX_BUZZ's derivation claimed both arms are developer-only and
therefore already clamped by DEV_BUZZ_BUDGET_CAP. Neither half is true; it is
the binding bound.
- the in-handler buzzBudget narrowing is defense-in-depth and unreachable
through the router, measured. Labelled, and so is the test that would
otherwise read as covering it.
Also adds the price-check counters the duplication had dropped, and tests for
the per-app denial exit, the audit-row dimension, the tag slot, the $type
bounds, and a mixed native+foreign workflow.
Kept as a flag, not a fix: `stepTimeoutSeconds = maxBuzz` bounds a
GPU-second-metered step and nothing else, so for the per-unit-priced types this
arm opens, an unquotable submit reserves maxBuzz and may bill more. See the PR.
* fix(app-blocks): second review round — two real leaks in the output splitter
Delta re-review of the fix commit. Two of the findings are live defects the
first round's fix introduced, and both are in the one function whose stated
job is "no second image channel".
1. A blob LIST leaked every url when any element was not blob-shaped. The
predicate used `every`, and `Blob.url` is optional upstream -- so one
blocked or not-yet-available sibling disqualified the whole array, left the
key unstripped, and forwarded every OTHER element's raw url through
`stepOutputs`. Measured. Now `some` + filter: a partly-blob list lifts the
conforming elements and the key is stripped either way.
2. A TOP-LEVEL blob list was forwarded whole. The array short-circuit ran
before the top-level blob case, so a type whose entire output is a blob list
had the same shape as `transcode` and no handling.
Both now have tests, and both the `every`->`some` and the drop-the-top-level
mutants die.
Also from the round:
- The submit phase filed its missing-quote outcome under `estimate_absent`.
That label is defined as "before any spend exists", so the ONE event with
money behind it -- a submit reserving at the declared ceiling because no
quote was had -- landed in the no-spend bucket, blended with estimate
traffic. The phase is threaded now; the submit records `absent`.
- The router's own fake orchestrator reply carried no `name`, so since the
name gate landed the router's `snapshotFromWorkflow(submitted)` had been
dropping the step on every test in the describe and nothing noticed. The
fake now echoes what the real orchestrator echoes, the submit asserts the
stamped name, and stamping a different one kills 29 tests.
- The settle seam is joined on the Redis key: both persist and settle run for
real against one key-addressed store, so a wrong workflow id or a wrong cap
key in the persisted record now fails.
- Comment corrections the delta made necessary: the "images IS POSTURE-GATED"
safety argument at `queryAppWorkflows` and `publishGenerationOutputs` no
longer covers the whole array; `detail.step`'s "never client text" contract;
and the schema's claim that the router hands over "this same object" (zod
rebuilds the top level, so it never could).
- The in-handler buzzBudget copy gets a distinct message, so the test pins
WHICH gate refused instead of resting on a comment.
- Vacuous assertions removed or given controls: the dev-session leg is now
driven for real (reserve, deny + refund, and the settle record), the per-app
denial asserts the idempotency release, the price-check test pins the emit
count, and the gated-image fixture derives width/height from the projection
with unequal values.
- The 19-key `it.each` collapses to two representatives: the key list left the
implementation last round, so the other 17 cases pinned nothing.
* fix(app-blocks): third review round — the depth-1 walk missed two live types
The output walk is recursive now, bounded at depth 4. A depth-1 walk shipped in
the last round and was then measured against the live spec: of the 50 step
types, three carry blobs deeper than the top level and TWO ARE ALLOWED on this
arm. `polyGen.basicAnimations` is a plain object holding six `Model3DBlob`s,
and `training.epochs[]` carries a `model` plus a `samples[]` of media. Every
one of those urls was forwarded raw -- reachable by the app, invisible to the
publish path and to the per-viewer gated read, i.e. the second image channel
the splitter exists to prevent.
Worse than the omission: the round-2 comment offered a list of nineteen
property names as the measurement that made the key-agnostic predicate
trustworthy, and `basicAnimations` was IN that list -- as a blob key, which it
is not. The list read as coverage of the thing it missed.
The blob predicate keys on `available` + an identity field rather than
`available` + `url`. `Blob.url` is optional upstream -- which is the premise
the array rule rests on -- so a BLOCKED blob whose `url` key is simply absent
failed the shape test and was forwarded whole: no url escaped, but its
`blockedReason` and raw `nsfwLevel` did, and the module's "a dropped blob
cannot ride out through `rest`" claim was false.
Metric polarity. `absent` is documented at the counter as "the submit is
REFUSED, nothing is reserved" -- the opposite of what this arm does, which is
reserve the declared ceiling and proceed. The counter now documents the third
site and its inverted polarity, and the pass-through submit emits a PAIR
(`quoted` / `absent`) so `absent` has a denominator: without one it falls when
submit volume falls, which reads as healthy.
Test fixes, each of which was a fix from the last round passing for the wrong
reason:
- The router's fake orchestrator HARDCODED the echoed step name instead of
reading it off the submitted body, so the read side was fed the right value
independently of what the router stamped. It echoes `opts` now. The
"29 tests" figure in the last commit message was wrong for that reason: a
repo-wide edit of that literal also hit the registry arm. The honest number
is one test, and it is now a real join.
- The unconditional strip had no control at the whole-value site; `contentRating`
was asserted by calling the implementation's own helper on a value whose
answer is also the fail-closed default; both dev-session tests sat on
`ceiling === maxBuzz` so the reserved amount was unpinned; the dev denial's
idempotency release was untested; and the settle seam joined `buzzCapKey` but
not `appSpendKey`.
Battery re-run: every mutant above dies, including the nine this round named.
* fix(app-blocks): fourth review round — the array rule and the recursion disagreed
The `some`-qualifies-the-whole-array rule from round 2 and the recursive walk
from round 3 interact in a way neither round's tests covered: `[blob, { nested:
blob }]` lifted the first element and discarded the second from BOTH sides --
never published, never forwarded. A silent media LOSS on the arm the app paid
for, and the one shape that needed both rules to be wrong at once.
The walk now lifts PER ELEMENT and recurses into the rest, so a non-blob
sibling -- and anything inside it -- survives. Consequence on the wire: an
array that held blobs comes back EMPTIED rather than removed, which is also the
more faithful reading of "forward everything except media".
Two mutants that survived the round-3 suite, both of them that round's own
headline claim being the half the tests could not see:
- `isOrchestratorBlobLike` -> `'available' in v` survived the whole battery.
Both existing controls guard the `available` conjunct; nothing guarded the
identity conjunct, so every object carrying an `available` key could be
deleted from the forwarded output undetected.
- `PASS_THROUGH_OUTPUT_WALK_DEPTH` 4 -> 3 survived: the cap was pinned to the
interval [3,4], not to 4. The `epochs` fixture needs >=3 and the too-deep one
needs <=4. A blob at exactly level 4 is the fixture that separates them.
The metric fix landed in the TypeScript docblock and not in the Prometheus HELP
text -- the string an on-call actually reads in Grafana. It still carried the
triage instruction the last round identified as INVERTED for this arm ("the
submit was refused, no spend, no generation") and did not mention `quoted` at
all. Both corrected, along with the "closed 6-value set" and "step is drawn
from the registry keys" cardinality notes, which the last round desynchronised.
Also: the dev-session fix from round 3 traded one fixture collision for its
mirror -- both tests moved to quote 31 where `ceiling === quotedBuzz`, so the
opposite mutant survived. One is back at quote 5. The dev-session leg's refund
on a submit throw was untested; `appSpendKey` in the settle seam was asserted
against a restated literal rather than the returned key.
Documented rather than fixed, because closing it is a decision: the "no second
image channel" property holds for blob-SHAPED values, and two allowed types --
`blobArchive` and `imageResourceTraining.epochs[].blobUrl` -- carry their url
as a plain string. The measurement behind the splitter enumerated blob-TYPED
fields, and a reader takes that for the whole population. Both docblocks now
say so.
* fix(app-blocks): fifth review round — the array branch was pinned for objects only
Two survivors of the round-4 change, both on the array branch it introduced:
- `depth + 1` -> `depth` in the array branch survived the whole suite. Both
boundary fixtures were all-object chains, so the array branch's own increment
was unpinned; without it a deep array chain overflows the stack, which is the
property the cap exists for.
- forwarding an array-valued element verbatim, never descending, also survived.
Every array fixture held objects or primitives, so `{ frames: [[blob]] }`
sent the url straight out through `stepOutputs`.
Both now have fixtures with arrays on the path.
Same fixture-constant collision as the last two rounds, one leg further along:
the dev-session REFUND test ran at quote 31, where the ceiling and the quote
are the same number, so `cost: ceiling` -> `cost: quotedBuzz` survived. Added
the quote-5 mirror, plus an emit count so a doubled refund cannot pass. The
settle record's `ceiling` had the same gap and now has a quote-5 case.
Doc corrections, each one a claim the previous round's own edit falsified:
- The depth cap's stated reason was "the input is app-supplied and only
size-capped: without it a cyclic or adversarially deep payload walks
forever". False, and contradicted by a paragraph twenty lines below it in the
same file: the value walked is the ORCHESTRATOR'S `step.output`, which the
client `JSON.parse`s -- acyclic and already depth-bounded. It is a walk-COST
bound over a response whose shape is open by construction, and the residue is
its price.
- The Prometheus HELP text, corrected last round for being inverted, had been
replaced with a different unconditional claim: "the generation RAN". `absent`
fires inside the quote, before every cap and before the real submit, so an
orchestrator that rejects the `$type` outright also lands there. Narrowed to
"not refused FOR LACK OF A QUOTE; a generation MAY have run", with the
discriminator named.
- The registry arm's closed-set test had `quoted` added to its allowlist last
round -- a value that arm cannot legitimately emit, and whose whole purpose is
that the two arms' `absent` mean opposite things. Reverted to its own six.
- `a caller cannot invent a fourth value` (the union is seven); the `epochs
needs >=3` rationale (stale the moment the walk became per-element); the
`appSpendKey` "derivation" justification (the mock's key is itself a constant,
so it buys nothing the literal did not -- said so rather than implying
otherwise); and a test comment still naming a variable the round-4 commit
deleted.
Two orchestrator-side questions this round raised are NOT in any file and are
not code changes: they are in the PR discussion.
* docs(app-blocks): sixth review round — the depth cap IS a stack bound
Both round-6 lanes found no executable defect in the delta. Every finding was a
claim in a comment, and the sharpest one was mine from the round before.
Last round I replaced the depth cap's rationale with "the value walked is the
orchestrator's `step.output`, which the client `JSON.parse`s, so it is acyclic
and already depth-bounded by that parse", and closed with "removing the cap
closes the residue outright". The second half of that is an invitation to a
crash. Measured on this repo's pinned node 24.19.0: `JSON.parse` accepts
200,000 levels of nesting, arrays and objects alike, while an unbounded
recursive walk of the parsed result overflows the stack between 1,000 and 2,000.
The parse is two orders of magnitude looser than the walk, so the cap is the
only thing bounding the recursion.
That matters because `splitPassThroughStepOutput` runs inside the pass-through
submit's post-submit path, where a throw is caught by a handler that refunds
every cap leg and rethrows -- on a generation that has already been created and
will bill. A `RangeError` there moves money in the wrong direction. Closing the
residue means an iterative walk, not deleting the cap. Both numbers are in the
docblock now instead of the reasoning, because the reasoning has been wrong
twice in opposite directions.
Two other claims, same shape:
- The `recordStepPriceCheck` docstring still carried the unconditional "a
generation RAN" that the HELP text was narrowed away from last round -- the
developer-facing copy and the operator-facing copy of one sentence, and only
one had been fixed. `absent` fires inside the quote, ahead of the static gate,
all three reservations and the real submit, so an orchestrator rejecting the
`$type` outright lands there with nothing having run.
- A test comment still stated the repudiated "the input is app-supplied and only
size-capped" rationale, and its sibling attributed the deep-array hazard to an
app payload when the walked value is the orchestrator's response. Both now
carry the measurement instead.
* docs(app-blocks): stop stating the stack-overflow depth — it is not a stable number
Rounds 5, 6 and 7 each corrected this paragraph's figure and each correction was
falsified by the next. Four independent measurements of "the depth at which an
unbounded recursive walk overflows" disagreed by more than 2x -- 1,669 / 3,050 /
3,383 / 3,418 on the same pinned node, depending on stack size, frame shape,
caller depth and whether the probe forked per depth. The quantity has no stable
value, so the instruction "say the number when you re-measure" -- correct for the
CATALOG-depth measurement, which is a fact about the orchestrator spec -- is what
generated three wrong numbers when applied to this one.
The docblock now states the ORDER (JSON parses iteratively and takes ten million
levels; the walk is recursive and goes a few thousand), says explicitly not to
write a number there again, and keeps the paragraph that earns its length: do
not "close the residue" by deleting the cap, because this runs on the
post-submit path where a throw refunds all three cap legs on a generation that
already exists and will bill.
Also removes the two duplicate copies of that measurement from the test file --
the duplication was the generator, and one of them was a banner restating the
constant's docblock with no claim about the test under it. The surviving comment
names the MECHANISM instead (without the array increment the cap never fires at
all), which is both truer and not a moving target.
Two more copies of the `absent` sentence, found by the same sweep that found the
first two:
- `quotePassThroughBuzz`'s `phase` docblock still said this arm's `absent` means
"it ran, it did not refuse" -- and cited the counter docstring that the last
commit changed to say the opposite. Third copy of one sentence; corrected.
- the HELP text and the docstring both told an operator to read `absent` against
"the submit-failure signal", which names no series in this module. Replaced
with what is true: this counter does not separate the two causes, and the
workflow status is what does.
* docs(app-blocks): remove the generators, not just the wrong claims
Eighth round, comments only again, and every finding was the previous round's
own fix.
- "four independent measurements in this file's history disagreed by more than
2x" -- the file's whole history contains exactly ONE overflow figure; the four
lived in commit messages. A maintainer following that sentence finds one
number and reads the prohibition as overstated. It is also a figure about
figures, and it went stale while it was being reviewed (a fifth measurement
came in at 4,096). Dropped the count and the locus.
- The paragraph whose entire lesson is that numbers here rot reintroduced one
ten lines later: "refunds all three cap reservations". It replaced a
count-free true statement, and it is wrong -- `refundBlockBuzzReservation`
refunds two keys, so four counters are involved, and two of the three legs are
conditional so the real count ranges 1-4. Another comment in the same function
already says four. Back to "every cap leg it reserved".
- Two opposite instructions about "a number" sat ten lines apart in one
docblock, both anaphoric: "say the number when you re-measure" (the catalog
depth -- a stable fact about the orchestrator spec) and "do not write a number
here again" (the overflow threshold). Each now names its quantity.
- The triage pointer that replaced "the submit-failure signal" named no series
either: there is no workflow-status metric anywhere in `src`, and in the very
cause it must identify -- the orchestrator rejecting the `$type` -- the submit
rethrows and no workflow exists to have a status. Kept the limit, dropped the
pointer: only whether a submit produced a workflow separates the two causes,
and nothing here carries that.
- The `absent` polarity fact was stated in four places, and the fourth asserted
what another comment says rather than a behaviour -- the exact coupling that
broke in rounds 6 and 7. Trimmed to its own fact, so the cross-reference goes
with it.
Six rounds of this ladder were spent on one paragraph. What ended it was not a
more careful number; it was deleting the number, the count of numbers, and the
cross-reference that made a third file's edit able to falsify this one.
* docs(app-blocks): drop the last counts and the last self-justifying aside
Ninth round found no false claim and no executable defect. Three trims, all on
text the eighth round introduced:
- The parenthetical defending the docblock's two "number" instructions against
being read as contradictory. The same commit's fix -- each instruction naming
its quantity -- already removes the contradiction, so it defends against
nothing; and it called the catalog depth "stable", which contradicts the
sentence three lines above it ("a new upstream $type is ALLOWED by
construction, so one extra wrapper level reopens this leak"). What actually
distinguishes that number is that it is re-derivable from the spec, which is
what it says now.
- "no series HERE carries that" works in the docblock, where "NOTHING IN THIS
MODULE" sets it up two lines earlier. It does not work in the Prometheus help
string, which an operator reads in a metric browser with no module in view.
Both now say "no App Block series", which is stronger and still true.
- The last two counts of the same reservation legs: one comment said "all three
reservations" and another, in the same function, said "all four counters".
Both are true readings of different things (numbered sites vs refunded keys)
and two of the legs are conditional, so neither number holds generally. Both
are count-free now.
* docs(app-blocks): there IS a series that separates the two `absent` causes
Round 9 widened "no series HERE carries that" to "no App Block series carries
that" to fix an antecedent, and in doing so made a defensible sentence false in
both of its homes. Verified against the code: the pass-through submit persists a
settle record only after a workflow exists, and the terminal settle emits
`civitai_app_block_customcomfy_wallclock_seconds{engine="passthrough"}`
independently of the accrued cost -- declared in the same module the comment
said carried nothing. The two `absent` causes differ exactly in whether such a
sample can exist: the ran-at-the-declared-ceiling case persists a record, the
orchestrator-rejected-`$type` case throws before the persist.
So the clause now names that series, with its limits stated (it needs a terminal
observation, drops a wallclock past its top bucket, and has no per-event join to
the counter). An authoritative "there is no signal" is worse than no note at
all: it sends a triaging operator away from the one series that answers the
question.
Also corrects a comment my own change made stale two files over: the two
`customcomfy_*` histograms' cardinality argument rested on their labels being
"enum-resolved from the recipe registry". This arm feeds them `passthrough` /
`__passthrough__`, which come from no registry -- they are constants precisely
because the pass-through `$type` set is open by construction. The bound still
holds; the stated reason did not, and that staleness is what made this round's
false claim easy to write.
* docs(app-blocks): 240 is the top bucket, 600 is the drop threshold
Three corrections to the clause the previous commit added, and this is the last
of them.
- "drops a wallclock over its top bucket" conflated two numbers the same file
distinguishes 475 lines above: the top wallclock bucket is 240s, the drop
threshold is MAX_CUSTOMCOMFY_WALLCLOCK_SECONDS = 600s, and a 300s sample IS
observed in +Inf. The false version makes the lower bound sound looser than it
is and hides the number an operator needs -- 600s is reachable on this arm via
queue-wait plus the terminal-poll gap even though the step timeout is <=180s.
- Deleted the provenance sentence recording that two earlier drafts of the same
clause said no series carried it. That is change-log narration and
reviewer-justification in one line; the clause now names the series, which is
the whole protection. The history belongs in these messages, which is where it
is.
- "Anything that adds a post-paid arm adds one pair here" is true of the two
constant-label arms enumerated above it and false of the next addition the
module itself anticipates: flipping postPaidSettle on a registry step entry
feeds the resolved variant and the step id, so it adds one pair per
(step id x variant). A future editor sizing that change off the old sentence
under-counts.
* fix(blocks): case-fold the platform-internal denylist — 'XGuardModeration' was allowed
On the pass-through `kind:'step'` arm this denylist is the ONLY control, and the
value it guards is a caller-supplied string forwarded verbatim to an
orchestrator this repo does not own. The lookup was a plain
`Set.has(stepType)` — exact and case-sensitive — so
`assertStepTypeAllowed('XGuardModeration')` returned ALLOWED while the live
spec's key is `xGuardModeration`.
Whether the orchestrator matches `$type` case-insensitively is not knowable
from here and is not ours to assume in either direction, so the control has to
hold for BOTH answers. If it does match case-insensitively, this was a complete
bypass of the denylist on the one arm where nothing else stands behind it.
Watched RED before the fix, and not by assertion: the six re-casing cases plus
the error-message case failed 7/7 against the unfolded lookup while the
negative control passed, so the bypass was demonstrated rather than argued.
After the fold, 20/20. Mutating the probe back to the unfolded form fails 12 —
wider than 7, because the fold has to be on BOTH the set and the probe to be
coherent, which is the property worth pinning.
Safe by measurement rather than by hope: all 50 `$type` keys in the live
`WorkflowStepTemplate.discriminator.mapping` are distinct when lowercased
(measured 2026-09-17 — 50 keys, 50 distinct-lowercased), so folding case cannot
make an ALLOWED type collide with a denied one. It denies a strict superset of
what it denied before. The docstring records that measurement and says to
re-take it before adding an entry whose lowercasing could collide.
The refusal still names the caller's own spelling, not the canonical one — a
guard that silently rewrites its input is a worse diagnostic than one that
quotes it, and there is a test for that.
|
||
|
|
5613c79d72 |
feat(feed): serve withMeta and fromPlatform from the feed service (#4920)
Both are single bits the feed already filters on; they were unmapped only because the flags behind them were a stale one-off load. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
473b692093 | 5.1.110 v5.1.110 | ||
|
|
0baf7e313a |
fix(generation): keep Pony, Illustrious and NoobAI on sdcpp by default
#4746 grouped the SDXL derivatives with Flux1/FluxKrea as comfy-only, forcing them onto comfyui with no toggle. They belong with SDXL: the enhancedCompatibility toggle is back (off = sdcpp, on = comfyui), and they rejoin the 2-for-1 quantity bonus, which derives from the toggle list. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
6cb780eb23 |
fix(buzz): let the reconcile confirmation survive the list repopulating (#4924)
* fix(buzz): let the reconcile confirmation survive the list repopulating A successful 'Check now' invalidates getDepositHistory, the list stops being empty, and the empty-state branch unmounts. The mutation lived in CheckDepositsNotice, so its success state went down with the branch: 'Found N deposit(s)!' was destroyed in the same tick it was produced and the user was left looking at an idle 'Check now'. That happened on exactly the flow the control exists for. Move the mutation into a hook called by DepositHistory, which spans both branches and does not unmount, and pass the one instance down. The mutation, its scoping and its rate limit are unchanged. Closes ClickUp 868m6j63r. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(buzz): drop a reconcile result once the deposit list moves under it Owning the mutation above the branch fixed the confirmation vanishing, and extended every other terminal state by the same amount. A 'No missing deposits found' does not invalidate anything, so it would sit unchanged while a deposit arrived from the signal and rendered above it. Reset the mutation when the total changes, unless it found something -- that result stays true, and clearing it would undo the invalidation it just caused. Keyed on a previous-total ref rather than on the terminal state, so a zero result is still shown at the moment it arrives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(buzz): stamp the deposit total at click time instead of syncing it in an effect Replaces the previous-total ref and its effect. That version updated the ref before testing the mutation state, so a deposit arriving WHILE the request was in flight consumed the comparison: the label then never went stale, in the one window the user is most likely to be in. It also reset a mutation mid-flight if the condition were ever widened, where disabled/loading read isPending alone. Stamp the total in onMutate, which runs at click time and cannot be outrun by the request, and derive staleness in render. No effect, no ref sync, nothing to order. A found-result stays exempt: it is the one result that always moves the list, because it invalidates the query itself, so treating it as stale would wipe the confirmation with its own refetch. Also close a hole in the guard. It counted occurrences of the text ".useMutation", which extracting the hook had made one by construction - a branch calling the hook itself still counted 1 while fully restoring the bug. It counts invocations now, and names the branches that must not call it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(buzz): move the hook call below the total it now reads The previous commit gave useReconcileDeposits an argument but left the call where it was, 33 lines above the declaration of "total". Same scope, const, so every render of DepositHistory raised ReferenceError: Cannot access 'total' before initialization. The deposit history was dead, not degraded, on a money surface. Typecheck reports it as TS2448; the source guard passed over it in 483ms, which is the clearest statement of what that guard does and does not see. Extract the staleness rule into isResultStale and table-test it, so the "!found" term is pinned by something that runs in CI rather than by a browser test that no CI job runs. The rule moved rather than being copied - a second definition beside the test would change with it and check nothing. Correct the exemption comment again. A found-result does NOT always move the list: reconciling a deposit already listed as Confirming credits an existing row via upsert and adds none. The exemption is still load-bearing for the normal case; the absolute was not true. Also pin two things the guard was not seeing: that the hook stays module-private, which is what makes scanning one file sound, and that the total is stamped in onMutate rather than in render. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * style: prettier the new test files --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
7b81489daf |
fix(db): re-predicate Post_blockPublishedAppId_idx so the sweep query can use it (#4923)
The index from 20260913120000 is valid, tiny, and unusable by the query its own
header documents.
Measured on the production primary with the index live:
WHERE metadata->>'blockPublishedAppId' = $1
-> Parallel Seq Scan on "Post", cost 962412
WHERE metadata ? 'blockPublishedAppId'
AND metadata->>'blockPublishedAppId' = $1
-> Index Scan using "Post_blockPublishedAppId_idx", cost 2.34
That is the exact full scan the index was created to prevent, ~411,000x apart.
It is a provability limit rather than a costing preference: with enable_seqscan
off the planner marks the sequential scan Disabled and uses it anyway, so there
is no viable index alternative. Postgres cannot derive "metadata ? k" from
"metadata->>k = v" - semantically equivalent, since ->> yields NULL for an
absent key and NULL = x is never true, but predicate_implied_by reasons over
operator rules and knows no such relationship between those two jsonb operators.
Fixed structurally rather than by comment. "(metadata->>k) IS NOT NULL" IS
provable from "metadata->>k = v" - measured with both variants present, where
the planner selects the IS NOT NULL one and cannot use the ? one - so
re-predicating makes the obvious query the fast one. Documenting "remember to
restate the predicate" would leave a guard someone has to read and obey, and the
moment it exists for is an incident, which is the worst moment to be relying on
that.
Semantic delta runs the right way: ? matches a key present with a JSON null
value and IS NOT NULL does not, but the server writes a non-null OauthClient id
unconditionally and a sweep matching = $1 could never return such a row anyway.
Drop-then-create is safe here specifically because the index covers zero rows
and has no code reader yet - nothing in src/ queries the marker, the sweep is
run by hand. The header says what to do instead if it ever becomes load-bearing.
The superseded migration keeps its reasoning but gains a banner, because its
bottom line is a query nobody should copy.
Applied by hand per the repo's migration policy. The header carries the
statement_timeout trap (300000 ms on this cluster, and a killed CONCURRENTLY
build leaves an INVALID index) and the regclass-quoting trap that makes the
obvious "is it there?" check silently match nothing.
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
e1c65a602b | 5.1.109 v5.1.109 | ||
|
|
e90aabeb98 |
feat(seo): give tags a display name, and fix model and comic page meta
Tag names are stored lowercase, so the tag page rendered "Lora AI Models" for
our largest tag. Tag.displayName carries the casing people recognise; the page
capitalises the name when it has none, which is right for almost every tag.
6,207 tags were seeded from the base model and ecosystem names.
The column rather than a lookup table, because base models and ecosystems are
moving into the database, and because casing a capitalise-fallback cannot reach
("3d" -> "3D") is a property of the tag, not of any model list.
Also: /models gets a title and description aimed at non-brand queries, and a
comic that green cannot show no longer serves an indexable "not available" page.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
||
|
|
1f3e70fbbe |
fix(creator-shop): stop a pack save re-snapshotting member payouts, and give moderators a pack edit path (#4910)
* feat(cosmetic-store): give moderators an edit path for pack products A pack is a CosmeticShopItem with cosmeticId null, and the generic product form requires one, so packs were given no Edit control at all rather than a form that fails on every save. Route a pack row to CreatorShopPackModal instead, which is the editor the creator manage page already uses for them. The save path needed no change: updateCreatorShopPack already accepts a moderator, and it computes ownership, the resale check and the price floor against the pack's owner rather than the editor. updatePack now also invalidates the queries a pack edit makes stale outside the manage page - the moderator list, the creator storefront and the community hub. A price or contents edit drops the pack to PendingReview, so all three were showing a listing that is no longer on sale. Not covered: availableFrom, availableTo and archived, which the pack editor has no fields for. No pack in production has ever had any of the three set. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(creator-shop): stop a pack save rewriting what its members' creators are paid The pack editor sent price and memberCosmeticIds on every save. Both are meant to be omitted when unchanged - the schema comment on memberCosmeticIds says so - and sending them unconditionally made a title-only edit: - re-snapshot every member's floorAmount, which computePackPayouts uses as the basis each member's creator is paid on, so a member repriced since the pack was built silently changed a third party's payout - rewrite meta.packMemberCount, which is the count assertPackPurchasable compares against precisely so it cannot shrink with the pack, disarming the guard that refuses a pack whose member has gone - drop the pack to PendingReview, taking a live listing off sale Send both only when they changed, and name any member the pack has lost so editing the contents no longer drops it without saying so. Two more the same editor carried, now reachable from the moderator list: the four scalars were seeded from the caller's row and never reconciled against getPack, and the query client runs at staleTime: Infinity, so a stale row could write an old price back over the creator's own change. Hydrate them from the server and keep Save disabled until it has answered. Rejected and archived packs are refused up front rather than at save. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(creator-shop): let a pack with an unavailable member still be title-edited Omitting memberCosmeticIds made the server fall back to the pack's stored member list, which includes members whose listing has since been archived, and then re-assert bundlability over it - so a title-only edit threw on exactly the packs unavailableCount exists to describe. Previously those saved, by silently pruning the missing member, which is the payout bug the previous commit fixed. The three asserts validate an INCOMING membership, so they now run only on one the caller supplied. Bundlability still applies when the price moves, because the floor it is checked against can only be summed over members that resolve. Also from review: a failed getPack left a filled-in form whose Save could never enable and which said nothing, and hydration silently overwrote anything typed before the query landed. Surface the load failure and keep the fields disabled until the server has answered. Measured before deciding: 0 of 40 production packs currently have an unresolvable member, so this was latent rather than live. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(creator-shop): stop a title-only pack edit failing on checks it never touched Three checks ran on every update regardless of what changed, so an edit that moved only the title was refused over a member's later, unrelated decision - the same "form that takes what you type and fails on save" this editor was added to remove. - the price floor is re-summed from TODAY's list prices, so a foreign member raising their listing blocked a typo fix, and the only way out was raising the pack's price, which re-snapshots every member and unlists it - the Blue Buzz check dead-ended it outright: the switch is disabled while blockers exist, so Save went grey with no way to clear it and an alert that explained blue rather than why - the Add button stayed live during the getPack window, and hydration replaces the selection rather than merging, so items added there vanished Both server checks now run only on the edit that makes them meaningful, and the four derivations of "this edit invalidates the stored floors" collapse to two named atoms. They used two truth functions that part company on an empty member list - reachable from anything that is not the zod-validated router - and split they would have run the checks while skipping the write. Resale consent is deliberately NOT re-checked on a price move: revoking sellableByOthers withdraws an offer from future resellers and was never a term of an existing listing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(creator-shop): let the Blue Buzz switch be un-ticked, and pin what the gates mean The switch was disabled whenever a member blocked blue, but a pack saved as blue-accepting hydrates it ON, and canSubmit refuses that combination - so the one control that clears the refusal was frozen and a title fix could not be saved at all. Un-tickable, never un-un-tickable. acceptsBlueBuzz now follows the same rule as price and memberCosmeticIds and is sent only when it changed. Without that the server-side scoping added last commit was inert for its only caller, which sent the field every time. The guard pinned that `reSnapshot` is USED but never what its operands mean: `const repriced = false` left every pinned spelling intact and silently reverted the whole price path - no floor check, no floorAmount re-snapshot, no drop to PendingReview. Measured green before this commit, red after. The floor gating itself was likewise unpinned. Also removes a comment of mine that was wrong: `[]` is truthy, so the two spellings of that condition never disagreed, and the collapse is a readability win rather than a fix. Three slices gain an empty-guard so a drifting marker fails naming the marker instead of blaming the code it was reading. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(creator-shop): make the Blue Buzz toggle wait for hydration, and pin the decision The switch was the only interactive control in the modal without `awaitingPack`. Before getPack lands there are no members, so no blockers, so it was enabled - and a moderator who ticked it had the toggle overwritten by hydration. Since the field is now only sent when it differs from that same hydrated value, the change was then discarded silently. The guard could not see any of this. Pinned, each reproduced green first: - the `?? !!meta.acceptsBlueBuzz` fallback. `?? false` flips a blue-accepting pack to blue-declining on a TITLE edit, and it only became reachable because the previous commit made the payload conditional - the server-side scoping of the blue check, which is the other half of the invariant that payload change implements on the client - the floor comparison DIRECTION, for the same reason canSubmit pins its `&&` - the switch and the canSubmit term it exists to unblock, which are one invariant and were pinnable only as a pair - a positive for the blue payload spread, which had only a negative, so deleting it outright passed and made the setting permanently unsavable The hydration slice now covers the whole effect and pins all six seeds. Its comment claimed the cover and members were "pinned by their own cases"; they were pinned by nothing, which is the second comment of mine this branch has had to correct. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(creator-shop): pin the contentsChanged predicate, and say why Save is blocked Dropping the `!` from contentsChanged makes it true whenever a member is UNCHANGED, so the contents go on every title edit and every floorAmount is re-snapshotted - the money decision this guard exists to protect. Both pinned substrings survive that, so it was green; `.some` to `.every` was too. The predicate is now pinned whole and both mutations redden. The blue-buzz alert now says saving is blocked and what turning it off costs. On a pack stored blue-accepting whose member has since opted out, the client refuses a title-only save that the server would accept, so unticking is the only route to fixing a typo. Leaving that stricter-than-the-server behaviour as it is - the state is invalid against the invariant - but a greyed-out Save beside a message about members left the connection for the moderator to make. Scoping canSubmit to match the server exactly is a behaviour change; it is on the follow-up ticket instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(creator-shop): stop the client refusing edits the server would accept Three more of the same shape, found by the final review round. canSubmit refused a title fix on a pack that already accepts Blue Buzz whose member has since opted out - even though the field is not sent and the server never checks it, so turning Blue Buzz off on someone else's listing was the only route to fixing a typo. That is this ticket's own bug in miniature, and the previous commit had added copy EXPLAINING the block rather than removing it. Scoped to `blueChanged`, the predicate the payload and the server already use, and the copy is gone with it. The Remove button had none of the guard the Add button gained: hydration replaces the selection, so a removal made during the load window was silently undone. Same list, opposite button. uneditableStatus restated the rejected-vs-archived rule and dropped its history term, so a pack archived AFTER a rejection was told to "Restore it before editing" - which the server refuses as REJECTED_IS_FINAL. Uses the canonical wasLastReviewARejection, as the Manage list already does. Also: members are now resolved only for the edits that read them. Every consumer sits behind one of the scoping predicates, so a title-only edit was paying for a query - measured 1.1ms and a seq scan of every shop item - whose result nothing looked at. The comment claiming the resolve had to be unconditional was describing a floor check this branch had already moved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(creator-shop): pin the rejected-vs-archived rule and the Remove button guard wasRejected was new code with no assertion at all, and it is what the commit that added it exists to fix: drop the history term and the alert tells a moderator to restore a pack the server refuses as REJECTED_IS_FINAL. Reddens now. The Remove button's hydration guard was pinned by a COUNT of `disabled={awaitingPack}` occurrences, which is worthless - there are six, so deleting the one being guarded still cleared the threshold. Measured green against exactly the mutation it was added for. Replaced with a slice of the Remove button itself. Left green, recorded on the follow-up rather than dropped: inverting the ternary that `needsMembers` guards, and the pre-existing `memberIds` fallback. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
e174d3ba00 |
Merge pull request #4925 from civitai/feat/generator-step-warnings
feat(generation): surface orchestrator model-retirement warnings in t… |
||
|
|
23c4e072d8 |
fix(training-studio): make tile videos actually play
Reported by a dev alongside the tester feedback: dataset video tiles were static, and videos imported from the generator never played at all. Two causes. The DataStep tile and the generation picker rendered bare <video muted> — no loop/playsinline/preload and no play trigger, unlike the sample components that already hover-play correctly. And the generator import mapped every item to previewUrl, which for a video is a STILL thumbnail — a <video> pointed at a JPEG can never play. Video and audio imports now keep the real blob URL (preload="metadata" holds the cost to a first frame until hovered; images keep the resized preview), which also means video captioning receives the actual video, matching the upload path. The hover-play handlers existed as identical private copies in SampleImage and SampleGrid, and the two broken sites would have made four — extracted to $lib/video-preview.ts and shared by all four. Verified live: uploaded webm tile shows its first frame, plays on hover (currentTime advancing, unpaused), pauses and rewinds on leave. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DV3Ku4Eu9qTtzd61zt19dZ |
||
|
|
0069dd5d5d |
feat(generation): surface orchestrator model-retirement warnings in the generator
The orchestrator reports deprecation on a workflow step (`WorkflowStep.warnings`, `@civitai/orchestration-client` beta.106). whatIfFromGraph now carries them to the client, one per distinct code + message, omitted when there are none — so the notice reaches the user before they spend, not after the model stops working. Both generator footers render the message in the priority alert space, above the sdcpp branch: that branch always claims the slot (its MultiController decides internally whether to draw), so anything after it would never render. A warning therefore suppresses the 2-for-1 banner while a retiring model is selected, and the banner returns on the next estimate once the model changes. Message only. The warning names no resource, so there is nothing to tie it to a checkpoint row or a picker card without guessing which resource it means. A malformed payload skips the banner rather than rejecting the estimate — a rejected whatIf disables submit, which is not a price worth paying for a notice. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
70de6e548a |
fix(utils): decode entities on the first normalizeText call, and keep the decoder off the feed path (#4912)
* fix(utils): decode HTML entities on the first normalizeText call normalizeText resolved `he` through a fire-and-forget dynamic import that assigned `decode` in a `.then` callback while returning an identity function in the meantime. `getHe()` is only reached for input containing `&`, so the first such input in a process was normalized without being decoded, and two callers normalizing the same input either side of an `await` could observe two different normalisations of it. Several audit paths do exactly that. Import `decode` statically instead. `he` is ~98KB of source, almost all entity table; the tradeoff is recorded in the file so the next reader does not have to re-derive it. The two tests are named for the decision rather than the mechanism, and both go red if the import is made lazy again. Verified by reverting the module and re-running the file: 2 failed, exit 1, each an assertion naming the wrong value. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(utils): anchor the decode tests to values, not to each other The before/after-await case asserted only that the two reads agreed. A decoder that warms up after the wait leaves both reads undecoded, so parity holds and the case went green on a variant worse than the one being fixed. Both reads are now anchored to the decoded value. Adds a third case pinning what is specific to this module rather than to `he`: decoding runs before the accent fold, covers numeric and hex references, and is single-pass. Trims the module comment to the load-bearing facts and stops the test header from re-deriving the same rationale. Mutation results, one file each, exit codes read from log files: - lazy import restored: 3 failed (3), exit 1 - warm-up delayed past the wait: 3 failed (3), exit 1 (previously 1 green) - `he` swapped for a compact `&`-only decoder: 1 failed | 2 passed (3) - decode and fold swapped: 1 failed | 2 passed (3) - as committed: 3 passed (3), exit 0 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * test(utils): pin the absent-input branch and say what the post-await read catches Adds the one unpinned branch in the function: absent input must come back as an empty string, not as the value it was handed. Removing that guard reddens the new case alone (`expected undefined to be ''`) and nothing else in the file. Rewrites two comments that pointed at the wrong thing. The post-await read was described as not independently protective; it is the only assertion here that catches a decoder correct on the first call and wrong later, which is the shape bundle pressure takes once lazy loading is closed off. Measured: an eviction mutant (correct on load, identity after 5ms) reddens that case and passes the other three. Also notes why the fourth case keeps its inputs in one instance, since splitting them would let a memoizing decoder satisfy each in turn. Mutation results, one file per run, exit codes read from log files: - as committed: 4 passed (4), exit 0 - lazy import restored: 3 failed | 1 passed (4), exit 1 - decoder evicted 5ms after load: 1 failed | 3 passed (4), exit 1 - absent-input guard removed: 1 failed | 3 passed (4), exit 1 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * perf(utils): keep the entity decoder out of the chunk the feed loads `hasNsfwWords` was the only thing putting `he` in the shared client chunk, and it never needed the entity decode: every one of its eight callers passes a title or a name, and those render as text, so a stored `é` displays literally and decoding it would make the check disagree with what the reader sees. Splits the accent fold into its own dependency-free module. `normalizeText` composes that with `he` for the prompt paths, so the fold has one definition and the decode has one definition. Nothing about how prompts are handled changes. Measured on the prod replica across the four surfaces `hasNsfwWords` is called on - Model.name, Article.title, Post.title, Tag.name - 5 rows of 25.7M contain any HTML entity, and all five are `&`, which cannot change a word match. The bare-ampersand counts in the same query are in the tens of thousands, so the zero is a real absence rather than a filter that could not match. Guard test, with both controls run: - decoder import re-added to audit-base: 1 failed | 1 passed (2), exit 1 - accent fold removed: 2 failed | 4 passed (6), exit 1 - as committed: 6 passed (6), exit 0 The guard reads source text, so it catches a direct re-import only, not a decoder arriving transitively. That limit is written into the test. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * refactor(utils): harden the fold-only guard and trim the comments Behaviour of the shipped code is unchanged. Two review findings on the guard, both of the shape "this test can stop working without going red": The guard blessed utils/fold-diacritics.ts by name and constrained nothing about it, so the likeliest reintroduction - teaching that helper to decode entities so the two stop disagreeing - left every case green. It now asserts that module imports nothing at all. The guard matched prose, so a double-quoted import, a require, a bare import() or a deep path all evaded it. It now extracts import specifiers and compares them, which also removes its dependence on stripping comment lines. The accent fixture picked a word whose first `e` could be word-final, and prepareWordRegex ends every expression with a not-followed-by-alphanumeric lookahead that a trailing combining mark satisfies. Such a pick would have passed with the fold removed. It now selects an `e` followed by a letter and asserts the mark did not land word-final, so a word-list edit that breaks the property fails loudly instead of quietly. Adds the unaccented word as a legibility control and splits the negative control into its own case. Comments trimmed to the edits they protect: the prod row counts move daily and belong in the commit record, not in the file, and the size fact now has one copy. New controls, one file per run, exit codes read from log files: - foldDiacritics taught to decode: 1 failed | 3 passed (4), exit 1 (was green) - audit-base importing `he` with double quotes: 1 failed | 3 passed (4), exit 1 - accent fold removed: 1 failed | 3 passed (4), exit 1 - as committed: 8 passed (8), exit 0 The word-final-fixture assertion has no control of its own; it guards against a future data change, not against a mutation of this tree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
19bb665f45 |
feat(hubs): allow moderation tags as hub sources, both directions (#4919)
* feat(hubs): allow moderation tags as hub sources, both directions Reverses a deliberate decision rather than filling a gap. HUB_TAG_SOURCE_FILTER excluded Moderation and System in both directions — Justin's call, 2026-09-04, recorded in the constant's own comment, on the reasoning that the browsing level already enforces it. It does not. The browsing level is a coarse nsfwLevel bitmask ANDed across the whole feed, so it cannot express "keep this band but drop images tagged sexy". That is a per-tag exclude, and it was the only thing a hub had no way to say. Justin reversed the call on 2026-09-17, for both include and exclude. System tags stay out on arithmetic rather than judgement: both System image tags are `unlisted`, and unlisted rows are dropped by a separate clause in getTags and in hubTagWhere, so listing the type would offer 0 of 2. Moderation is 51 of 53 by that same count. Measured against prod 2026-09-17. hub-moderation-tag-vocabulary.test.ts is named for the decision so that the next review recommending a narrower vocabulary finds the argument rather than the conclusion. Closes 868m67cqe. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(hubs): answer the half of the old rationale the reversal did not refute Review round on #4919. Three defects, all in the record rather than the behaviour, plus one guard that could not fail. The 2026-09-04 call gave two reasons for keeping moderation tags out. The reversal comment rebutted only the first (the browsing level already enforces it — it does not, being a coarse bitmask ANDed across the feed). The second — that a tag exclude is a WEAKER control than the level, so offering it may leave a user believing they set something stronger — is still true, and nothing here makes it false. It was overridden, not refuted, and the comment now says so and names the trade: a cap the user cannot aim is worse than a filter they can. A future reviewer who reaches for that argument now finds an answer instead of silence. The picker's own comment still asserted the pre-reversal rule ("not the moderation or system vocabularies") one file from the constant that reverses it. It was the first thing a reader would hit when asking whether moderation tags are pickable. The two Moderation tags the 51-of-53 count leaves out are now named and characterised — `self injury` and `extremist`, both `unlisted`, not a carve-out — so the gap cannot read as a deliberate exception nobody documented. The vocabulary rationale and the entityType warning were two adjacent docblocks, so only the second attached as the constant's JSDoc and hover hid the argument. Merged. The picker guard asserted the source CONTAINS the spread and does not retype the array as a literal. Both pass over `[...HUB_TAG_SOURCE_FILTER.types].filter((t) => t !== TagType.Moderation)`, which re-narrows the client to exactly the pre-reversal vocabulary — the plausible regression, since whoever narrows it edits the spread line rather than retyping the array. Added a reshape assertion and verified all three against mutants: the real file passes; .filter(), .slice() and a retyped literal each fail. The guard also resolved its path from process.cwd(), so a run from another directory failed with ENOENT rather than an assertion. Resolved from import.meta.url instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
f96dc9d1fa |
fix(training-studio): first pass over consolidated tester feedback
Eight fixes from the #civitai-testers thread (ClickUp 868m67xr5):
- Remix/reuse no longer downloads every dataset image before navigating —
the reported indefinite "Loading…" — it hands off {air, caption,
workflowId} instantly and DataStep hydrates previews lazily behind
placeholder tiles (pool of 4, per-tile failure tolerated, object URLs
reclaimed on stale tiles and flow teardown).
- A label-format switch no longer destroys labels: text that came with the
dataset (zip .txt, reused captions) is kept on Img.sourceLabel and
re-applied verbatim in the new format; a switch that would discard
machine/hand labels asks first (Convert / Re-label / Cancel). The switch
aborts an in-flight auto-label drain so old-mode results can't mark
post-switch tiles labeled with empty labels.
- Spending beyond Blue requires an explicit confirmation naming the
Yellow/Green amount, failing safe to "up to the full price" when the
balance is unreadable. The element now seeds balances through a new
getBuzzBalances host capability (implemented by the embed page via
buzz.getBuzzAccount), and Train further's confirm names its non-Blue
share too. One derivation: nonBlueSpend in $lib.
- Video models accept image datasets again (the on-site trainer always
did): dropzone/picker/zip all take stills, each tile typed by its own
file so previews and zip round-trips stay correct. One ext<->media<->mime
table in $lib/media.ts — extracting it surfaced and fixed a real drift
(zip import minted audio/mp3 while the zip download knew audio/mpeg,
and the download's naming table was missing mov/mkv/bmp/flac/ogg/m4a).
- Krea 2 text-encoder training is locked (AI-Toolkit fails those runs
after ~20-30 min with no abort): TE LR input disabled with a tooltip,
and zeroed again at submit so stale param state can't smuggle it in.
- "Base model trained on" resolution order fixed: the picked card
(meta.cardType) now beats the coarse ecosystem fallback, so Illustrious
and custom-checkpoint runs stop displaying as generic "SDXL".
- Finished runs show "Expires <date> (N days)" — 30-day retention from
completion (fallback: creation, which only ever warns early), amber
inside the final week. RETENTION_DAYS imported from orchestrator-core,
not re-declared.
- "Train further" input relabeled "+ checkpoints" with copy stating a
checkpoint is a save point, not one pass over the dataset.
Not addressed here (tracked on the task): the Krea 2 orchestrator-side
root cause and abort, whatif pricing non-linearity, the homepage box CSS
(screenshot still needed), and the nine feature requests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DV3Ku4Eu9qTtzd61zt19dZ
|
||
|
|
b16346d214 |
feat(models): show Hugging Face imports as grouped cards, not a table
The row actions were unreachable. A filename is one unbroken word, so the source column widened the table past its scroll container and Cancel, Restart and Delete sat off-screen to the right — present, working, and never found. Each import is a card now, in a list of groups. Nothing has a fixed width and every group wraps, so no size can push the actions out of view; a test renders a card in a 240px column and fails on any overflow. The group header carries what belongs to the batch — name, rename control, file count, total size, age — and links to the repo at the revision the files came from, which is the commit rather than a branch that moves. The Unattached tab shows the same cards instead of one cramped line per file, so it now has progress, the stored URL, attach and detach. A transferred file no version has claimed can also be deleted straight from the queue, where before it could only be deleted from that tab. The four per-import actions moved into `useImportActions`, so the "could not free storage — Delete anyway?" prompt behaves the same wherever it is raised. The page is wider, with transfer settings in a sticky sidebar. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp |
||
|
|
eecd5b5987 |
fix(buzz): make the crypto deposit reconcile control visible (#4918)
* fix(buzz): make the crypto deposit reconcile control visible The self-service reconcile was an UnstyledButton inlined inside dimmed xs helper text, so depositors missed it and opened support tickets for deposits that had simply not been reconciled yet. Promote it to a compact Button in its own bordered strip. The mutation, its scoping and its 1/60s rate limit are unchanged. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * fix(buzz): stop the deposit notice contradicting its own button The prompt was folded into the helper sentence, so after a check the sentence still asked 'Missing a deposit?' while the button answered 'No missing deposits found'. It also sat in the dimmed line users skim, which is the line the ticket says they miss. Give it its own weighted line and drop it once a result exists. This and |
||
|
|
402090624c |
feat(feed): carry meta-derived image flags in a replicable table (#4917)
The feed service filters withMeta/fromPlatform on flags it cannot derive: Image.meta is outside the replication column list, being the largest column on the table. A narrow table keeps them replicable without the full rewrite a stored generated column on Image would cost. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
0a2732a1d3 |
feat(feed): serve the new-creator board from the feed service (#4916)
The board is at most 200 creators, well inside the feed's user-id ceiling, so the primary path resolves it like the follow list and passes it as the user scope. An unpopulated board serves nothing rather than the global feed. Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
dff6dc3c18 |
feat(model-comments): add copy-link and highlight support
Introduce deep-linking capability to model discussion comments: - Create getModelCommentThreadUrl() helper to centralize URL generation - Add "Copy link" menu item to model comment menu - Support highlight parameter for automatic scroll-to-comment on load - Refactor notification processors to use centralized URL builder Model-page comments lacked the deep-linking and highlighting functionality available in CommentsV2, making it impossible to share direct links to specific comments. This change brings feature parity by enabling users to copy shareable comment links and supporting navigation to highlighted comments. |
||
|
|
ac9f21d254 |
feat(blocks): record the generation type on block spend attribution (#4903)
* feat(blocks): record the generation type on block spend attribution Every App Block generation that spends the viewer's Buzz writes one `block_spend_attribution` row, but the row recorded the event and the money basis and nothing about WHICH capability ran. An image generation and a chat completion were indistinguishable in the table that exists to answer "what did this app's users spend on". That blocks the next piece of work: the author fee is moving from a platform-funded percentage bounty to an additive, author-set, per-generation fee, configurable PER GENERATION TYPE. A fee per type cannot be reasoned about from data that never recorded the type, and an event written untyped can never be typed retrospectively — so the column goes in ahead of the fee itself. What this adds: - `block_spend_attribution.generation_type` — one additive, nullable TEXT column holding the APP-FACING type key: `textToImage`, `customComfy`, or a registered step id (`convert-image`, `chat-completion`). A step id implies `kind: 'step'`, so one column covers the axis. - `resolveBlockGenerationType` / `isBlockGenerationType` (`src/server/services/blocks/generation-type.ts`) — the single definition of that mapping, bounded against the step registry the wire schema already derives its enum from. - The type threaded through `recordSpendAttribution` and wired at all three submit paths (textToImage, customComfy, step). Two decisions worth flagging: - The value is the REGISTERED STEP ID, never the orchestrator's `$type`. The registry describes each id as a permanent public wire commitment; `orchestratorType` (`convertImage`, `chatCompletion`) is the orchestrator's internal spelling and is free to change. The durable column follows the stable one. Pinned by tests on both sides. - No CHECK constraint and no enum. The step registry is designed to grow additively, and a CHECK would make every new step type a migration. The value set is bounded in code instead, at the resolver and again at the write. Resolution is total and fail-open: an unresolvable body persists NULL rather than throwing, matching how `sharedContentKey` / `contentAuthorUserId` resolution already degrades on this fire-and-forget path. Existing rows stay NULL — there is no backfill, because the type was never recorded on them and a backfill could only guess. The migration is committed for history and requires MANUAL application; it has not been run anywhere. * feat(blocks): record the generation SUB-TYPE, not just the kind The column sampled the generation axis at two different depths: a kind:'step' submit recorded the capability, while textToImage and customComfy recorded only the kind and discarded sub-axes that are real, available at the call site, and recorded nowhere else. Since the premise of the column is that an untyped event can never be typed retrospectively, those sub-axes were being permanently lost. Value grammar is now `<coarse>` or `<coarse>:<subtype>`. The coarse key is everything before the FIRST colon and stays exactly the four that existed before, so the (unbuilt) per-generation-type author fee keys on it unchanged. Every value carries at most one colon. textToImage:txt2img | :img2img | :img2img-edit customComfy:<registered recipe id> | customComfy:inline convert-image | chat-completion (step ids, no subtype) The image workflow CLASS is not derivable from the body — a source image maps to img2img on an SD-family ecosystem and img2img:edit on an edit-capable one, and the discriminator is the checkpoint's ecosystem. The router therefore passes back the class buildTextToImageInput already stamped onto the graph input, i.e. the class actually billed, rather than re-deriving it (a re-derivation could disagree, and would re-run a function that throws on an unsupported ecosystem, on a fire-and-forget closure that must not throw). Interpolating registry ids makes the value space composite, so isBlockGenerationType is now a SHAPE test rather than array membership: coarse key in the closed set, subtype in the closed set that key allows. Every lookup stays an array membership test, so a recipe id arriving from a registry lookup is bounded exactly as a step id was — getRecipe indexes a plain object literal and fails open on prototype keys just as getStep does. Values are built in one place (composeBlockGenerationType) which validates its own output against that bound, so the producer cannot emit something the write-side re-check would refuse. An unusable subtype degrades to the bare coarse key; an unresolvable body still degrades to NULL. The service-side re-check is kept and covers the new shape. Two standalone guards (subtype-is-colon-free, subtype-is-non-empty) were written and then DELETED: a mutation sweep showed neither could be turned red, because every allowed subtype is colon-free and non-empty so the membership test already refuses both. A guard no test can kill reads as coverage while providing none. The property now rests on the allowed sets plus a registration-time invariant guard asserting no registry id contains a colon. No schema change and no DDL change — the column stays a nullable TEXT. The migration header, the Prisma field doc and the module header were corrected for the widened value set, and the migration header gained the post-apply verification step (the schema-drift "Column present" check). Tests: red-before/green-after measured both ways. - vs the branch point |
||
|
|
e06bce9eb2 |
Merge pull request #4905 from civitai/feat/retire-image-search-index-with-graceful
feat(search): retire image search index with graceful no-op pattern |
||
|
|
58d066fbaa |
fix(creator-program): include license fees in peak earning calculation
The peakEarnings ClickHouse query used to compute the Banking Cap only counted `compensation` and `purchase` transaction types, excluding `licenseFee` earnings minted separately by deliver-creator-compensation. This left creators who shifted to license-fee income with caps frozen on older pre-license-fee months, making license-fee surges invisible to the cap calculation. Now the peak earning predicate includes both `compensation` and `licenseFee` as bankable creator earnings. Note: only this cap query counts license fees; getPoolForecast and earnedCache queries in this file and buzz.service.ts still exclude it. Those queries should be updated too if forecasts should reflect license-fee income. |
||
|
|
2a456be12d |
docs(db): mark the sell-merge column default applied to prod (#4908)
* docs(db): mark the sell-merge column default applied to prod Applied 2026-09-17 against the writer (banner: PROD (writable) -> civitai@localhost:25060/civitai), with SET lock_timeout = '3s' ahead of it so the ACCESS EXCLUSIVE acquisition could not queue and block readers. Read back from the writer AND the replica: five members. Control in the same query, allowDerivatives and allowNoCredit still 'true', so it reads per-column defaults rather than answering yes. The header is the record -- _prisma_migrations is not the source of truth here -- so leaving it reading NOT YET APPLIED would be a false claim in the one place an operator looks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * docs(db): record the lock_timeout the apply was run behind The next person applying DDL to "Model" will not find this otherwise: it is an apply-time property rather than part of the migration, so it lives in no file unless it is written down. ALTER TABLE takes ACCESS EXCLUSIVE and the acquisition can queue behind a long transaction, parking every reader of the hottest table in the product. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> |
||
|
|
5414ab6b5b | 5.1.108 v5.1.108 |