870 Commits

Author SHA1 Message Date
briant d6d5f856a6 feat(models): a server-side Hugging Face transfer API that attaches on completion
Our own tooling can now queue a repo's files onto a model version in one call
and poll, rather than driving the moderator page by hand. `WEBHOOK_TOKEN`
guards it, so it is the same internal surface as the rest of `api/admin`.

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

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp
2026-09-18 16:44:35 -06:00
briant 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>
2026-09-18 10:50:09 -06:00
briant 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>
2026-09-17 16:48:33 -06:00
briant 3726a28811 fix(models): let stuck Hugging Face imports be restarted or deleted
Four imports were stuck on "The specified bucket does not exist". They were
started from a machine without B2 configured, so their uploads opened in the
default backend's bucket; when a production pod resumed them it sent that
bucket name to B2. A resume, abort, delete or pre-attach existence check now
uses the client for the bucket stored on the row, not the backend configured
now.

A stuck import had no way out: Restart resumed the upload that could not be
aborted, and Delete refused. Restart now always begins from nothing, in the
backend configured now. Both try to free what the row stored first; an upload
or file that is already gone counts as freed, and a bucket one backend does
not know is tried on the other. If removal still fails, the page shows the
storage error and offers "Restart anyway" / "Delete anyway", which logs where
the leftover is. Deleting bytes a model file still points at stays refused.

The job's give-up path now reads the richer abort result correctly and logs a
failed abort, and the queue list sorts a batch by file size then id, so rows
that share a createdAt stop reshuffling on every poll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp
2026-09-17 11:18:17 -06:00
briant 093111bdb2 docs(seo): record what shipped and what was decided in the SEO review
seo-improvements.md is rewritten around the work that has landed:
- a table of the seven SEO commits, linked to their sections
- tag pages: both soft-404 causes and their fixes; the minimum-model
  threshold deferred (start at 2 with an exemption, only if a re-export
  still shows /tag/ dominating); new-tag name rules dropped, with the
  reasons (numeric commas, trailing-comma duplicates, long titles) and
  the cleanup caveat that TagsOnImageNew has no foreign key to Tag
- structured data marked live; ecosystem copy and its next steps;
  the articles sitemap rules and why the recency rule was dropped;
  video pages deindexed everywhere
- an open question on whether Googlebot passes civitai.red's Cloudflare
  challenge
- "not doing" scoped: widening the model sitemap, not the curated
  articles one; the filter redesign is not an SEO fix

seo-audit.md: the canIndex reference points at _app.tsx:419; P0 detail
pages rendered through Gated are ticked as such; comics is flagged as
still lacking an NSFW-aware deIndex; the tag page and articles sitemap
are ticked; the removed sitemap-tools.xml entry is gone.

seo-sitemap-migration.md: the article query is no longer described as
LIMIT 1000 by publishedAt, the removed sqlByColor reference names
domainFilter, and the monthly-partitioning design is marked not
scheduled.

No absolute Search Console figures; the repo is public.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-17 11:16:35 -06:00
Briant Diehl e7f371ee05 Merge pull request #4906 from civitai/feat/image-scanning-ingestion
feat(ingestion): scan images with imageScanning behind a Flipt flag
2026-09-17 11:06:44 -06:00
briant 6471abff78 feat(ingestion): scan images with imageScanning behind a Flipt flag
ClickUp 868m5wp5r. Image and video ingestion can submit the single
orchestrator `imageScanning` step instead of `wdTagging` + `mediaRating`,
gated by the Flipt flag `image-ingestion-image-scanning` (per image id,
off by default). Flag off, the submitted workflow is unchanged.

- `/api/webhooks/image-scan-result` stays the only callback. It fetches
  the workflow and routes on its step types, so both shapes can be in
  flight across a flag flip.
- Stages both pipelines share move unchanged to `image-scan-pipeline.ts`.
  The legacy service keeps its own parsing and flow.
- New `image-scanning-result.service.ts` reads the imageScanning output
  directly (images and video frames), keeps only general tags, and
  records csam without acting on it, as legacy does.
- The new pipeline logs to Axiom as `image-scanning-result` /
  `image-scanning-ingestion`, submits under
  `image_scan_submitted_total{lane="imageScanning"}`, and writes scanner
  audit rows as version '2'.
- Remove the non-orchestrator scanner path: the webhook's legacy body
  handling, the `IMAGE_SCANNER_NEW` Redis toggle, `ingestImageBulk`,
  `image.ingestArticleImages`, `/api/webhooks/reingest-images`,
  `/api/internal/add-missing-phash`, `/api/mod/scan-images`, and the
  `IMAGE_SCANNING_ENDPOINT` / `IMAGE_SCANNING_MODEL` env vars.
- Bump `@civitai/orchestration-client` to 0.2.0-beta.106 for the
  imageScanning types.

Keep the flag off until a deploy has fully rolled out: pods on the
previous build cannot read imageScanning callbacks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-17 10:49:02 -06:00
Luis E. Rojas Cabrera 9204f00617 Merge pull request #4875 from civitai/feat/generation-air-resources
Training Studio: generate with epochs in place + publish a run to a model page
2026-09-17 11:40:57 -04:00
Luis Rojas 50bf730eea feat(training-studio): publish a run as a model page, linked both ways
The studio's Publish button (standalone and embed) hands workflowId + the
selected epoch to /models/train/from-orchestrator, which builds the Draft
chain and performs the wizard's "Select Model File" step unattended — copies
the epoch blob into our storage, creates the Model file, marks the version
Approved, seeds the post form with the epoch's samples — then lands on the
MODEL wizard's "Edit model" step (the model-version wizard never offers
title/description/tags). Re-entry with the same epoch redirects server-side
straight to the wizard; a different epoch re-finalizes onto the same file;
the manual epoch picker stays reachable as the failure fallback.

The workflow and model are linked both ways: the publish entry stamps
{ modelId, modelVersionId } into the workflow's metadata when the draft is
created, and the publish handler adds published: true when the model actually
goes public (owner-token mint with cross-user cache bypass, merge-write
because the orchestrator replaces metadata wholesale, best-effort so an
unreachable orchestrator never fails a publish). The studio renders "View
draft" / "View your model page" off those fields via the new modelPageUrl
host capability — branching on run state first so a published run can never
fall back to a Publish CTA, and keeping the draft link visible after blob
retention expires.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DV3Ku4Eu9qTtzd61zt19dZ
2026-09-16 21:09:48 -04:00
Manuel Emilio Urena 62a7ee9ff1 fix(trainer): stop bare "fu"/"fk" tags blocking LoRA submission (#4883)
* fix(trainer): stop bare "fu"/"fk" tags blocking LoRA submission

obscenity's `fuck` phrase carries the patterns `|fu|` and `|fk`, so a bare `fu`
or `fk` token matches. Legitimate Danbooru dataset tags hit that: a creator with
"fu manchu mustache" was hard-blocked from submitting, by a toast reading
"Reason: fuck" over a tag list containing no such word.

- `LIBRARY_OVERMATCH_TOKENS` excuses the exact tokens `fu` and `fk`, unioned into
  the filter's whitelist set. Deliberately not in `whitelist-words.json`, because
  `moderatorWhitelist` REPLACES that file: a list-only fix would reach the prompt
  audit and not the search gate, and a moderator emptying the row would re-break
  it. Only the bare token is excused; `fuk`, `fkin` and every `f?ck` spelling
  still fire.

- The profanity block now reports the word the INPUT carried rather than the
  dataset word it matched, so a `fagus` tag is blocked for `fagus`, not `fag`.

- The trainer splits severity through the existing `isSoftBlock`, so a
  profanity-only failure becomes the click-through the rest of the app already
  offers rather than a wall. The decision moved out of the component into
  `auditTrainingLabels`, which is unit-testable.

Docs: the whitelist section named one source of three, the minimum-length rule
was scoped to our own list only (obscenity's dataset is added wholesale and does
ship 2-character patterns), `analyze()`'s documented return shape omitted
`matchedWords`, and Compromise was claimed as a dependency in five places with
zero imports anywhere in `src/`.

ClickUp 868m5agjq, Freshdesk 72556.

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

* fix(trainer): name the flagged field in the soft-block modal

A trigger-word-only profanity hit marks no image card, so a modal worded
"These labels look like they might be inappropriate" pointed the creator at
labels that were all fine. The title, body and cancel button now name whichever
of labels / trigger word was actually flagged.

Also narrows the moderator blocklist copy: it claimed the code-level token
exemption applies "on every path", but `clean()` consults no whitelist at all,
so rendered text is still censored. That contradicted both the feature doc and
the test pinning it in this same change.

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

* fix(profanity): drop "fk" from the overmatch exemption

Measured against the Tag corpus: 143 rows carry a standalone `fu` — `fu hua`,
`fu xuan`, `fu'ri'na`, `fu manchu`, `fu dog` — so excusing it buys real tags.
`fk` has 3 rows (`fk`, `fk zero`, `sexy attire fk`), none used on any model, so
it excused nothing and only let the abbreviation through.

`fk` is blocked again, which the tests now pin alongside `fkin`/`fking`/`fkn`.
The constant records what was rejected and why, so the next token is measured
rather than guessed.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 20:05:01 -04:00
Justin Maier 61dbbeaf37 feat(announcements): back dismissals with an account-level store (#4892)
* feat(announcements): back dismissals with an account-level store

Dismissals lived only on the device, so dismissing an announcement on one
device did nothing on the next. Adds an AnnouncementDismissal table as a
cross-device backstop.

The device stores are untouched and still the only thing the render path
reads, including SSR: the cookie keeps frame 0 exact for this device and
anonymous visitors keep working exactly as before. A signed-in dismissal
also writes a row, and what the account holds is merged into the device
store while the session runs, intersected with the ids each surface shows.

The read joins to Announcement and returns ids for currently-live
announcements only. That bound is server-side on purpose, and it is what
lets the table grow safely and cleanup stay lazy — a row for a dead
announcement is inert rather than wrong.

The migration is NOT applied. It needs running by hand.

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

* docs(announcements): correct the dismissal storage claims

The generator-announcements doc still said dismissals live in localStorage
with no table and no per-user tracking, and that dismissal is per-browser.
The store has been a cookie since the SSR carousel work, and a signed-in
dismissal is now also recorded against the account.

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

* fix(announcements): close the review findings on account dismissals

Review found three assertions that could not fail, an untested bulk
delete, and a retention rule that cannot reach most retired rows.

- The cleanup job now also collects dismissals for announcements that
  were disabled and left untouched for the grace period. A large
  minority of rows carry no endsAt at all, so the previous rule would
  never have collected them.
- The job has tests: the comparison direction, both retention arms, the
  chunking, and which pool each statement uses.
- The dismiss mutation returns nothing. A count of the ids that matched
  answered "which of these announcements is live" for any signed-in
  caller, including announcements targeted at someone else.
- The browser auth read moved behind isSignedInBrowser() and is tested.
  Misspelling the property made the whole feature inert with every test
  still green.
- pruneDismissals now delegates to selectLiveDismissals rather than
  restating the same filter fourteen lines away.
- Dropped two redundant guards in the merge hook and one in the request
  planner; the intersection already covered all three, so the tests
  naming them proved nothing.

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

* test(announcements): cover the sequences that make dismissal sync inert

Three assertions from the last round could not fail on their own.

The merge tests all rendered with one stable props object, so narrowing
the effect's dependencies to [] passed every one of them. That is the
production sequence: the account's dismissed ids have no initialData and
are always absent on the first render, so a mount-only effect merges
nothing, for anyone, and the account store becomes write-only. The hook
is now driven with a second props object.

The router had no test at all. Losing .default({}) rejects every
signed-in user's query and the merge silently never happens; losing
protectedProcedure or the domain stamp is as quiet.

The pool-routing test asserted only that two calls did not happen, which
is satisfied by a job that issues nothing.

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

* test(announcements): cover the live-set arriving late, and name the bound

The merge suite covered a late account list but not a late live list, so
dropping liveIds from the effect's dependencies passed every test. That
axis is not hypothetical either: the creator feed has no initialData, so
its live ids are empty on the first render and arrive on a later one,
and a hook that stops watching them never merges a creator dismissal.

The oversized-list test threw bare, so it passed on any throw and could
not see the bound itself change. It names the message now.

Also records the invariant the device store rests on: callers pass the
ids of the action in hand, never the store's contents, so the cookie is
a sink for account state and never a source.

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

* revert(announcements): leave pruneDismissals alone

The dedupe was correct and is still worth doing, but not here. The
prune-on-absence fix is its own change with its own control, and having
it open on lines a schema PR rewrote puts the blame for any later
breakage on the wrong commit. Filed as a follow-up against PR #4892.

selectLiveDismissals stays: the merge needs the intersection without the
no-change sentinel, which is what made these two separate functions.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 16:56:40 -06:00
briant cbc3124738 fix(seo): deindex video detail pages on every domain
Reverts the indexing half of b7a23ed785 (2026-06-29), which made
safe-rated /images/:id video pages indexable on green as video watch
pages.

A Search Console Performance export filtered to the Videos search
appearance (green, last three months) shows the video-result clicks
coming from pages that embed a video alongside real content: model
pages carried about two-thirds, then posts, articles and collections.
The /images/:id video pages, indexable for two and a half months by
then, appeared once, with effectively no clicks.

The pages are thin by our own choice: one generated string serves as
the title and both VideoObject fields, there is no meta description,
and the only per-page text is the prompt, which stays out of search
snippets. Indexing them adds near-identical pages to a site where
Google already declines a large share of what it crawls.

Mature video pages on civitai.red stay deindexed for the same reason:
same template, and about six in seven videos are mature. Red's own
Videos appearance can't inform this, because those pages were
deindexed during the window it covers.

The "Video isn't on a watch page" warnings are left alone. They concern
model and post pages that embed a video, which are the pages earning
the video clicks; steering Google to /images/:id instead would likely
move that credit to the thin page.

The old comment also claimed Google Images still surfaces these images
through the ImageObject schema. A noindex page's images are not
eligible from that page, so that claim is gone. The VideoObject and
ImageObject schema are left in place: they are inert on a noindex page.

docs/seo-improvements.md section 5 records the decision and evidence.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 15:46:53 -06:00
Briant Diehl f31f755229 Merge pull request #4878 from civitai/feat/huggingface-model-import
Feat/huggingface model import
2026-09-16 14:40:38 -06:00
briant 2b2034f0b4 fix(generation): restore the ecosystem picker and make Show more reliable
- useResizeObserver: pending entries are merged and flushed once per frame.
  A later batch or any consumer's unmount used to cancel the shared frame and
  drop entries that are never redelivered, so a queue prompt could keep a
  stale "not clamped" and never offer Show more.
- LineClamp: both variants share one measure path that re-checks on every
  resize while collapsed and whenever the text changes.
- form-graph generator: back to generation_v2's BaseModelInput beside the
  workflow picker, and ResourceSelectInput for the model field. The
  checkpoint row, the ecosystem rail and the picker plumbing only they used
  (rail/footer slots, role 'checkpoint', options override, PickerRail) are
  removed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-16 12:02:30 -06:00
briant f34a1b04af Merge remote-tracking branch 'origin/main' into feat/huggingface-model-import 2026-09-16 11:25:49 -06:00
briant abb1463ba8 chore(models): retire the old Hugging Face importer
`src/server/importers/*`, `GET /api/import` and the hourly
`processImportsJob` are deleted. The importer created a Model with no
versions, hardcoded `baseModel` to SD 1.5 and pointed `ModelFile.url`
at huggingface.co, so it never transferred anything — and it carried a
second Hugging Face client alongside the new import path.

Removing the job from the `jobs` array is what stops the scheduler
running it.

The `Import` table, `ImportStatus`, and the `fromImportId` columns on
Model and ModelVersion are left in place. Nothing reads them now, but
dropping them is a migration over existing rows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp
2026-09-16 11:16:08 -06:00
briant ac97ff9701 feat(models): rename a Hugging Face import group at any stage
The picker on Manage files filters imports only by group name, so a
misnamed group was unfindable and there was no way to fix it: nothing
called `renameGroup`, and it refused once any file had started
transferring — which is when a typo is usually noticed.

Each group header on the Unattached tab now has a rename control. The
queued-only rule is gone; the name never reaches a storage key, so a
rename at any status desynchronises nothing.

`renameGroup` is now scoped by the group's current name as well as repo
and revision. One repo at one revision can be imported as two batches,
and renaming by repo alone would have merged them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp
2026-09-16 11:16:07 -06:00
briant 8fddfbe6e0 feat(models): add Hugging Face imports to a version from Manage files
Moderators can now put an already-transferred Hugging Face file onto a
model version without leaving its Manage files page. The "Add from
Hugging Face imports" button opens a picker that lists unattached
imports, grouped by batch and filterable by group name; each file is
attached once a type is chosen for it.

The type is never defaulted. `suggestFileType` deliberately makes no
suggestion for primary weights, and that label decides whether the
version loads, so a list's first entry is not a safe fallback. A
suggestion, where one exists, is shown only as the placeholder. The
import page's own Attach control dropped the same fallback.

The picker opens through the dialog store. Manage files is itself a
store dialog, and an inline Modal rendered at Mantine's lower default
z-index, behind it, so the button appeared to do nothing.

`FilesProvider` seeds its file list once, so a file created outside its
upload path never appeared until a reload. `adoptFiles` adds the named
files, read from `getByIdForEdit` (the primary): append-only, so unsaved
metadata edits and in-flight uploads are untouched. The server-row
mapping is now one function shared with the initial seed.

The attach loop runs one file at a time, attempts every file after a
failure, and reports the ids it created. A lost import claim leaves a
created file whose id appears only in the error, so every failure is
shown and the notification stays open. `getModelFileTypeOptions` is
now the one definition of the file-type list and its labels, used by
the picker, the Attach control and the creator's own type select.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp
2026-09-16 11:01:01 -06:00
Luis Rojas b641043cc8 feat(training-studio): open the sidebar generator in place for embedded epoch handoffs
Embedded at civitai.com, an epoch's Generate no longer navigates: the host provides an optional
generate() capability that seeds the epoch's raw-AIR resource and opens the globally-mounted
sidebar generation panel — URL untouched, no reload. The synthetic-resource construction moved
out of the ?air= ingestion effect into a shared seedRawAirResource, so the URL entry and the
panel path can't drift. RunDetail prefers the callback (button) over generateUrl (link, still
the standalone's behavior); both absent hides the affordance. Contract documented.

Verified live: embed click opens the seeded panel in ~50ms with whatIf pricing (reload sentinel
intact); standalone keeps the absolute _blank link. Element build + both typechecks + 30
targeted tests green; maintainer tested both hosts and OK'd.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DV3Ku4Eu9qTtzd61zt19dZ
2026-09-15 21:27:13 -04:00
briant e60d745f97 fix(seo): show tag pages their content when the period filter finds none
Search Console reports ~157k soft 404s and the drilldown is 88% /tag/*. The
cause is not thin tags -- it is the default filter. modelFilterSchema defaults
to period: Month with periodMode: 'published', so the grid only shows models
whose lastVersionAt falls inside a 30-day window. A tag whose models all
shipped earlier renders nothing while the meta description and CollectionPage
schema on the same page advertise the full all-time count.

Measured on the prod replica: 224,624 of the 243,469 tags that have published
models -- 92% -- render an empty grid under the default, and 22,931 of those
have five models or more. /tag/badik has four models, all published Feb-Apr
2026, and shows none of them under a promise of four.

Adds `periodFallback`, an opt-in flag on getAllModelsSchema. When set, and the
FIRST page of a period-filtered query returns nothing, getModelsInfiniteHandler
retries once at AllTime. Only /tag/:name passes it: an empty result is the
correct answer on a browse feed, so this must not become ambient behaviour.

Three guards, each mutation-tested:
- first page only -- the paging loop advances input.cursor, so the original is
  captured before it runs; without this, paging to the end of a tag would throw
  the reader back to page one of a different result set
- opt-in only -- otherwise the browse feed silently stops honouring its filter
- the retry resets the cursor -- an empty page that still reports a nextCursor
  leaves a stale one behind, and the retry would resume mid-list

The flag lives on the server schema and reaches ModelsInfinite as its own prop
rather than a ModelFilterSchema field, because that schema is what gets
serialized into the `model-filters` localStorage key -- a per-page concern must
not be persisted into every user's stored preferences.

🔴 THIS IS A STOP-GAP. It fires on exactly zero results, and the bad experience
does not start at zero: a tag with 255 models where 3 shipped last month shows
3 of 255, the fallback does not fire, and the page is still wrong in the way
that matters. The real fix is for the tag page to derive its default period
from tag volume server-side -- getTagPageSeoData already returns the count and
is already cached for a day -- held as page-local state so the filter control
stops displaying a value the query is not using. When that lands, delete
periodFallback, periodFallbackApplied and the retry block. docs/seo-improvements.md
carries the reasoning and the closing condition.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mc2rXbJwfbTzhwcGAocMF
2026-09-15 17:05:21 -06:00
briant c60fd7c2e1 feat(models): import model files from Hugging Face server-side
A moderator pastes a Hugging Face repo URL at /moderator/huggingface-import, picks files, and our
servers fetch the weights into our storage — replacing a human downloading 20GB and re-uploading it
through the browser wizard. A finished import attaches to a ModelVersion as a ModelFile, which puts
it on the existing scan and hash pipeline.

The transfer is resumable by construction. A 20GB file cannot move inside one request or one job
run — jobs here hold a lock measured in minutes and a deploy rolls the pod — so it is a sequence of
independent parts: a ranged read from HF written as one multipart part. The row stores `uploadId`,
`partSize` and the parts written so far, and each cron run moves as many as fit in its budget. A
deploy costs one part, not the file.

Notes for review:

- Parts complete out of order, so the resume point is the SET of missing part numbers, never a
  count, and a resume addresses the bucket recorded on the row rather than whatever the backend
  config resolves to now.
- Every write inside a claimed run is fenced by `claimedBy`, and completion re-reads status from the
  PRIMARY first — without that, a cancel arriving during the final part still finalised the upload.
- `PART_SIZE_BYTES` is fixed at 16MB rather than `getUploadChunkSize`, whose 1000-part cap is a
  browser-presigning bound and would make part size (and so pod memory) grow with the file.
- Imported objects use the same bucket and the same `buildUploadKey` as a browser upload. Nothing
  about the import appears in the key; the HuggingFaceImport row is the index, because a key is
  immutable and a column is not.
- `official-model-admin` gains `hf-imports` and `attach-import`, so the upload step it used to hand
  back to a human is scriptable.

🔴 The migration has already been applied to production by hand; any other environment still needs
it. This repo never runs `prisma migrate deploy`.

🔴 Depends on `refactor/shared-upload-key`, which must merge first — this branch carries those four
files so it compiles, and they become no-ops on rebase.

Verified: typecheck, lint and prettier clean; 37 tests over the transfer engine, several
mutation-checked. Full suite 41,224 passed / 28 failed — all 28 pre-existing on main (9 verified
against a clean tree, 19 from a package.json/lockfile mismatch on @civitai/generation-metadata).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015cyrXpr87t9Tj3bnzRrUhp
2026-09-15 16:17:50 -06:00
briant 4fa44ae5f8 feat(seo): site-wide Organization/WebSite schema + breadcrumbs on detail pages
The site had no site-level entity definition: no Organization, no sameAs, no
WebSite. A crawl of a detail page returned two JSON-LD blocks (VideoObject and
Person) for a property of over a million indexed pages, so Google had no
structured statement of what Civitai is or what it is authoritative about --
which is what AI Overview citation and knowledge-panel treatment lean on.

Adds site-schema.ts, emitted from _app for the site-wide nodes and passed
per-page through a new Meta prop, `breadcrumb`. BreadcrumbList lands on model,
article and image/video detail pages; it was previously on /ecosystems only.

Three deliberate calls:

- The Organization node is green-only. sameAs is what ties our social accounts
  into the entity graph, and pointing those at the mature domain is a brand
  decision rather than a technical one. Red still gets its own WebSite node so
  the property is identified, just not attributed to the Organization.
- No SearchAction. robots.txt disallows /search/* and *?query= as thin
  duplicate content, so declaring a search target would contradict a rule worth
  keeping -- for a feature Google has been winding down since 2024.
- Breadcrumbs get their own <script> rather than joining the page's entity
  schema, because Gated augments meta.schema with paywall properties for
  verified bots; merged into a @graph root those would attach to the container
  instead of the entity.

sameAs uses the real profile URLs from the next.config.mjs redirect table, not
the /discord-style internal redirects the footer links through -- a redirect on
our own host proves nothing about account ownership.

docs/seo-improvements.md records the wider backlog this came out of, including
what NOT to build: the monthly sitemap partitioning in seo-sitemap-migration.md
should not ship on SEO grounds, because GSC reports "Discovered - currently not
indexed" in the low tens of pages. There is no discovery backlog, so a larger
sitemap hands Google nothing it does not already have.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015mc2rXbJwfbTzhwcGAocMF
2026-09-15 15:07:46 -06:00
Luis Rojas 200c93dc53 feat(training-studio): per-epoch Generate links via the generateUrl host capability
Every epoch with downloadable weights gets a Generate link (featured header + checkpoint rows)
handing off to the main app's generator with /generate?air=<epoch blob AIR>&workflowId=&name=.
The AIR comes from the same loraBlobAir builder train-further uses, off a shared
EpochModelOutput/epochModelKey so "usable weights" can't fork between the two paths.

generateUrl is an optional host capability: the standalone shell links absolute to CIVITAI_URL
(new tab); the main-app embed provides a relative same-tab URL only when both
generationAirResources and formGraphGenerator are on; absence hides the affordance entirely.
Contract documented in docs/training-studio-web-component.md.

Three svelte-review lanes run over the segment; findings applied. Link shape and both-host
behavior verified live in the browser.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DV3Ku4Eu9qTtzd61zt19dZ
2026-09-15 12:19:34 -04:00
Zachary Lowden 26b47fa98e docs(app-blocks): de-line six stale file:line cross-references, and correct a false justification (#4838)
* docs(app-blocks): de-line six stale file:line cross-references

Six citations named a file:line that no longer held what the citing
sentence claimed. Each was re-measured against main and replaced with the
SYMBOL, function or branch it means — a fresh line number would be wrong
again on the next edit, which is exactly how these six got here.

docs/features/app-blocks.md (4):
  - CACHE_TTL_SECONDS         :39   was an import stmt  -> name the file only
  - invalidateModelCache      :461  was a kill-list note-> name the file only
  - KILL_LIST_CACHE_TTL_MS    :480  was a catch comment -> name the file only
  - MAX_BLOCKS_PER_SLOT       :1849 was an NSFW comment -> installOnModel

Two bare line-lists in the SAME two sentences were also stale and are
de-lined with them: the four invalidateModelCache callers (cited 1950 /
2019 / 2055 / 2108, actually installOnModel / uninstallFromModel /
toggleEnabled / updateSettings) and the kill-list filter on a cache hit
(cited 657-664, actually the kill.has(r.blockId) filter on listForModel's
cache-hit branch). Leaving a known-wrong number attached to a clause whose
other half was just corrected would ship a statement measured false.

src/shared/constants/block-effective-scopes.ts (2):
  - blocks.router.ts:2787-2789     was a pinned-install count mapping
    -> grantScopes' `const ceiling = new Set(effectiveBlockScopes(...))`
  - scope-grant.service.ts:222-224 was the buzzBudgetPerDay opts field
    -> recordScopeGrant's `const incoming = Array.from(new Set(...))`

Matches the de-lining style three citations in that same file already use.

NOT changed, because they were re-measured and are ACCURATE — they are the
positive control proving this audit discriminates rather than rewriting
everything it touches: block-registry.service.ts:329 (the
OwnedNonApprovedPageBlockResolution docblock sentence), block-tokens
:1054 / :469 / :650 / :375-389 / :455-459, block-registry:2117-2119 and
:2104-2114, and block-manifest-validator.service.ts:478-496. Nine of the
eleven citations checked in that file are exact.

Comment/docs only; no behaviour change.

* docs(app-blocks): the full-bleed ledger's reason is an operator decision

The `WHY THIS IS CSS AND NOT A MANIFEST FIELD` block argued from a real
premise to a conclusion that does not follow. It said the manifest schema
is mirrored across three repos and this host is pinned to
@civitai/app-sdk@^0.14.0 while guests ship 0.35.x, "so a new manifest field
would be UNTYPED at exactly the point the host consumes it."

The pin is real. The conclusion was measured false: the host never reads a
manifest through an SDK type at all.
  - src/server/services/block-manifest-validator.service.ts imports nothing
    from @civitai/app-sdk (0 matches; 6 import statements as a positive
    control) and validates against this repo's own rules.
  - src/components/AppBlocks/types.ts DECLARES the host's own BlockManifest.
    Its one "@civitai/app-sdk" hit is prose in a docblock ("Matches
    @civitai/app-sdk/blocks v1"); the file has no import statements at all.

The conclusion — keep full bleed in CSS — stands, but on its actual author
of record: the repo owner decided full bleed should be managed by styling,
not a manifest field. PR #4812 built the manifest field and was closed
unmerged on that call. That decision is now stated as the reason, and the
false technical reason is kept alongside it, explicitly retracted, so it is
not rediscovered and acted on. Deliberately NOT replaced with a freshly
constructed technical argument: inventing a replacement reason is the
failure that produced the false one.

The three-repo mirroring IS true and is kept, now as the measured COST of
the alternative rather than as the reason. Verified first-hand that all
three copies declare `page` with additionalProperties:false and the
identical four keys: public/schemas/app-block/v1.json (canonical), the Go
CLI's schema/app-block.manifest.schema.json, and app-sdk@0.14.0's vendored
schemas/app-block/v1.json. Against the released CLI 0.1.101, adding an
undeclared key under `page` makes `civitai app validate` exit 1 with
"page: additional properties 'fullBleed' not allowed", locally, before any
network call (negative control); the unmodified manifest exits 0 (positive
control).

Comment-only; no CSS rule, selector or declaration changed. `/*` and `*/`
counts equal at 31/31 before and after.
ledgerSelectorSurvivesProdStrip.test.ts parses selectors out of this file
INCLUDING its comments, so it was instrument-validated rather than merely
run green: injecting a data-testid-keyed selector into this comment turns
it red naming data-testid (1 failed / 6 passed), and it is green at
16/16 with pageBlockHostMaxWidth.test.ts once reverted.

* docs(globals): cut the retraction to what is measured, not a fourth draft

The replacement prose asserted three things beyond the measurement, in the
paragraph that replaced a claim retracted for exactly that:

- "and it is strict" of the three schema mirrors. Measured false today:
  `@civitai/app-sdk@0.14.0`'s vendored copy is missing five top-level
  properties the canonical declares, and the Go CLI is missing one. The
  mirror is loose, and the drift sat there unnoticed.
- a re-vendor cost attributed to all three copies. Only the CLI was ever
  measured to block; nothing consumes the SDK's copy as a validator, and a
  manifest's $schema names the canonical URL.
- "its 'Matches @civitai/app-sdk/blocks v1' line" bound to `BlockManifest`,
  which has no docblock — that line belongs to the BLOCK_INIT payload type.

Cut rather than redrafted. Every sentence here is a claim that can rot, and
this block has now been wrong twice; a shorter one has less to be wrong
about. The comparative clauses ("one line", "needs no schema motion") went
with it: they argued for the mechanism under a label saying they did not.

Guard re-validated on this tree, not inherited: a literal `*/` planted in
the new paragraph turns ledgerSelectorSurvivesProdStrip and
pageBlockHostMaxWidth red (2 failed / 14 passed); restored, 16/16 green and
the comment balance is 31/31.
2026-09-14 17:37:07 -05:00
briant ede4510a5f feat(generation): store gate rules and generator messages per entry
Gate rules and generator messages move from one JSON array in
`system:features` to a sysRedis hash per store (field = id), so saving or
deleting one entry never rewrites the rest. Each store migrates itself on
first read, save or delete, copying the legacy array with hSetNX before
setting its marker; the legacy array stays as a backup.

The /moderator/generation-config page now lists compact read-only cards
and edits each rule or message in a modal, one save per entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:58:46 -06:00
briant 6b92ccb01a docs(prompt-analysis): record that the ideogram guide is live and unmeasured
STATUS.md listed `ideogram` as not deployed, but the orchestrator serves the
authored guide byte-for-byte; it went live after 2026-08-06 with no
measurement run. It was also sourced from the hosted Ideogram product rather
than the open-weights Ideogram 4 the generator now runs (PR #4766), so its
Magic Prompt and style-preset advice does not apply. Note that and mark it
for revision and measurement.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 11:53:52 -06:00
Briant Diehl d341910096 Merge pull request #4815 from civitai/feat/generation-gate-rules-and-messages
feat(generation): generator messages, selectable disabled gates, expe…
2026-09-14 10:01:32 -06:00
Zachary Lowden b0711ee568 feat(apps): let an App Block publish a real post from its own outputs (#4811)
* feat(apps): let an App Block publish a real post from its own outputs

Adds the host half of a new App Blocks capability: a block can ask the host
to create a REAL, published Post on the viewer's profile, built from the
app's own generation outputs and/or images it previously published, with an
optional model-version gallery attach.

This is a SIBLING of the existing shared-grid publish bridge, not an
extension of it. Two reasons decided that:

  * the existing publish payload carries a single required workflow id and
    cannot express a post built from several sources;
  * its consent copy says the images "become visible to other viewers of
    this app", which is false for a profile post — and that sentence is the
    security control, not decoration.

New scope `posts:write:self`, mapped to the existing "upload media & create
posts" OAuth bit. It is SENSITIVE (a manifest declaring it must justify it)
and CONSENT-PROMPTED (deliberately not consent-exempt), so a token carries
it only after the viewer grants it. Wired end to end: registry, sensitive
set, runtime binding, consent description, canonical manifest schema, and
both dev-mint allowlists. It is withheld from BOTH moderator-review mint
allowlists — a mod previewing an unapproved third-party app must never
publish public content under their own name.

Two procedures, because the consent dialog has to be trustworthy:
`previewPostFromApp` resolves, server-side, everything the confirm renders
(the exact copy, the tag names that will actually apply, host-fetched model
and version names, real thumbnails); `createPostFromApp` re-runs every guard
and writes. The block is sandboxed and cannot be trusted to display
truthfully, so the dialog asserts what the SERVER resolved, never what the
block sent. The preview confers no authority.

Controls, each with a negative test:

  * SELF-DEALING — refuses a gallery attach whose model is owned by the
    calling app's own publisher. This is the one control that removes the
    payoff of routing viewers' posts at your own models; everything else
    only raises the cost. A colluding second account still defeats it, which
    is what the attribution marker below is for.
  * The gallery gate is otherwise strictly stricter than the native path,
    which checks nothing at all: published + public + undeleted, or refused.
  * A block may NEVER mint a site tag. Requested names resolve against
    existing tags only; unmatched names are dropped and shown in the confirm.
    Moderation, system and admin-only tags are excluded even when they match.
  * Server-side text bounds and a link refusal on title and detail, plus
    blocked-content screening over title, detail AND the resolved tags (the
    native path screens only the first two).
  * Server-authoritative attribution in post metadata, using the same key
    and semantics as the image-side marker so one moderation sweep reads
    both. No post input schema exposes that field, so no client can forge or
    suppress it, and the badge must render from the column rather than from
    block-supplied copy. An index ships with it rather than after an incident.
  * A DEDICATED rate bucket for posts, separate from the image-weighted
    publish bucket; the per-image origin cost is still charged to the latter
    so this path cannot be used to bypass it.
  * A durable audit row for every outcome once the request is admitted,
    with a named Activity-feed sentence rather than the generic fallback.
  * Its own kill switch, independent of the App Blocks runtime flag, so
    widening that flag toward GA does not arm public post creation on the
    same day. SHIPS OFF: the flag does not exist yet, so an absent flag
    resolves false for everyone and the whole capability is dark as merged.
  * The confirm's exactly-once reply latch is the FIXED shape, so "declined"
    means no post exists even if the dialog is dismissed mid-write.
  * The write refuses if the resolved image set no longer matches the count
    the viewer confirmed — the one divergence every authorization check
    would happily wave through.

The write is atomic: create, adopt images, publish in one transaction. Image
adoption is a bounded update whose matched count IS the ownership and
provenance proof, so a row that changes underneath us aborts the whole thing
instead of leaving a public artefact nobody agreed to.

Known consequence, documented for app authors: adopting a previously
published image into a post removes it from the app's own shared grid, since
that read is scoped to post-less rows. An app cannot both keep an image in
its grid and let the viewer post it.

The min-trust gate moved to its own module now that it has a second caller;
the rule, its signals and its exact messages are unchanged.

The block-side message pair and the CLI manifest mirror are co-requisites
that ship separately and AFTER this deploys, since their drift guards
compare against the live published schema.

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

* fix(apps): declare the two new post procs in every host-rendering trpc mock

23 suites render PageBlockHost against a hand-listed mock of the tRPC client —
one entry per procedure the component reads. The component now reads two more,
so every one of those mocks returned `undefined` for them and the whole
component threw at render.

🔴 THE FAILURE DOES NOT LOOK LIKE A MISSING MOCK. The DOM comes back EMPTY, so
each assertion fails with "no element with data-testid=…" — which reads as the
element having been removed, in files that measure layout and have nothing to
do with posting. The geometry suite's own POSITIVE CONTROL failed too, and that
fixture is hand-built markup that never touches the new code; a reader would
reasonably conclude the harness was broken rather than the mock incomplete.

Caught by the geometry project, not by the new tests: every test added with the
feature mocks the service layer directly and none of them render the host, so
the seam between "the component reads a procedure" and "the mock declares it"
was owned by no suite that changed. That is the gap, not the fix.

Also adds a typed constant for the refusal codes the HOST itself emits on this
bridge, so a block can branch on them and a typo is a compile error. It is
explicitly NOT an allowlist: the reply's error field must stay shape-checked,
because server messages travel through the same field and a reply that fails
validation is dropped before correlation — wedging the block for ten minutes
instead of showing the reason.

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

* docs(apps): name the scan-timing constraint on posting an already-published image

An image published through the grid bridge is not immediately postable: that
bridge returns ids before any scan runs, and the adopt path requires a terminal
scan. An app that publishes and posts in one breath gets a refusal that reads
as a bug rather than as a wait.

* docs(apps): add posts:write:self to the block-scope table, and mark the table non-exhaustive

The table was already missing the shared-storage and collections scopes.
Adding one row while leaving four absent would make it look complete, so it
now says plainly that the constant is the authority and names what is missing.

* fix(apps): app-level post ceiling, refuse a non-terminal workflow, drop a dead re-export and three dangling citations

Four round-0 audit fixes on the app-post path.

1. AGGREGATION GAP — add an APP-scoped post ceiling.
   checkBlockPostRateLimit keys on blockInstanceId, which bounds ONE install, so
   an app with N installs gets N times the ceiling and nothing sees the total.
   Adds checkBlockPostAppRateLimit(appId) on its own ':post-app:' sub-namespace,
   checked alongside (not instead of) the per-instance bucket. 300/hour.

   The number is NOT data-derived and the code says so: it is 100x the
   per-instance ceiling, i.e. 100 distinct installs each at their own hourly max
   in the same hour, and it is a starting value to be revised from the
   block_scope_invocations audit rows. Sized loose on purpose — a too-tight
   aggregate throttles a popular legitimate app and reaches users as "posting is
   broken", which is worse and quieter than the abuse it prevents. The
   per-instance 3/hour is unchanged and still labelled a guess.

   Fails open on a Redis error like every sibling limiter, stated at the call
   site so nobody reads either bucket as a hard cap.

2. PREVIEW/WRITE DIVERGENCE — refuse a non-terminal workflow source.
   The generator of the race is a workflow that can still gain an output between
   the two phases. projectAppWorkflow already computes status and
   resolveOwnedWorkflowOutputs was discarding it, so the refusal costs zero extra
   IO and binds every caller. pending/processing are refused with BAD_REQUEST;
   succeeded/failed/expired/canceled are admitted because the output set is
   frozen in all four. An unrecognised upstream status fails closed. The terminal
   list is bound to the wire union with satisfies, so a status rename breaks the
   build rather than widening the gate.

   confirmedImageCount is KEPT and made REQUIRED, not deleted: the terminality
   gate removes one known race, the count pins the whole resolved set, so a
   future source arm or an expiring blob still surfaces as a refusal. An
   integrity check a caller may decline is not an invariant. It moves out of the
   shared preview/write payload shape — it is the write's echo of what the
   preview returned, so the preview cannot be asked for it.

3. DEAD RE-EXPORT + a comment that misstated a dependency. The re-export claimed
   to keep "every existing importer" working; the population is empty. Every
   importer of apps-shared.router takes appsSharedRouter/appsModRouter,
   sanitizeDiscordText, or the counter helpers — none takes assertSharedWriteTrust,
   MIN_ACCOUNT_AGE_MS or REQUIRE_PAID_TIER. Removed, with the enumeration recorded
   in place of the false claim.

4. THREE CITATIONS POINTING NOWHERE — "operator decision 3", "operator decision 4"
   and "the design note calling for Scanned on every image" referenced a document
   that is not in the tree. Inlined the substance at each site, written as
   decisions with the rejected alternative named, and dropped the pointers. No new
   doc: a pointer to a doc that can rot is what produced this.

Tests: app-ceiling refusal plus a different-app-unaffected case (the half that
proves app-scoping rather than a global key); the full pending/processing/
succeeded/failed/expired/canceled matrix plus an unrecognised status; ordering
against both ownership proofs; required-vs-optional on the confirm count.

* fix(apps): an app-created post was invisible to everyone but its author

Both `Post` triggers this path depends on are declared `AFTER UPDATE OF
"publishedAt"` — INSERT is in neither event list — and `writeBlockPost`
writes `publishedAt` inside the `post.create`. So neither fired, and
nothing else on this path writes `Post.nsfwLevel`.

The post therefore kept its schema default `nsfwLevel = 0` forever, and
both non-owner reads gate on it: `getPostDetail` admits a non-owner only
on `{ publishedAt: { lt: now }, nsfwLevel: { not: 0 } }`, and
`getPostsInfinite` masks on `(p."nsfwLevel" & browsingLevel) != 0`. A
post whose images are all `published` sources was a permanent 404 for
everyone but its author and absent from the profile tab and every feed,
while its images stayed visible in galleries — which reads as a cache
bug. Only that arm was broken: a post containing a fresh output recovers
by accident, because the output's later scan fires the Image trigger and
the job bit_ors over every image of the post.

`applyBlockPostPublishEffects` now re-issues both triggers. It ENQUEUES
`JobQueue(Post, UpdateNsfwLevel)` rather than calling
`updatePostNsfwLevels` directly, because the enqueue is what the trigger
does, so the consumer behaves natively and walks the post to the model
version it is attached to. Same fix seeds the `PostMetric(AllTime)` row
the metrics trigger would have created, so `ageGroup` is not left NULL.

Also in this pass:

- `droppedTags` reached the consent dialog unsanitised. It is the
  block's own tag strings echoed back, so a block could put bidi
  overrides and zero-width padding into the host surface that IS the
  security control. Stripped at the source in tag normalisation,
  re-sanitized at the render point, and included in the blocklist
  screen. The module doctrine comment claimed the app name was the only
  block-influenced value; corrected.

- `resolveOwnedWorkflowOutputs` claimed to return the same ordered
  projection the block saw and did not — it filtered the host
  allowlist, which silently renumbered later outputs, so an index could
  select a different image than the one it named. It now blanks the slot
  in place and the selection site refuses a blanked index.

- `applyBlockPostPublishEffects` had no direct test; its only coverage
  mocked it wholesale. Added a ledger test asserting the exact effect
  set, failing when it grows or shrinks, plus a test that drives the
  visibility symptom end to end rather than asserting a call.

- ClickHouse `nsfw` was hardcoded false for every app-created post. It
  is now derived from the adopted images, which is what the level the
  job queue computes will be built from.

- Two comments corrected: the write-trust module claimed a re-export
  that the other file explicitly denies, and the shared post preamble
  claimed preview and write can never drift when write-trust is
  deliberately write-only.

* fix(apps): make the nsfw-level enqueue atomic with the publish, and correct the trigger ledger

The enqueue that keeps an app-created post visible was issued from
applyBlockPostPublishEffects — a function the router calls AFTER
writeBlockPost's transaction has committed, and whose every rejection it
swallows into a log line. So a connection reset, pool exhaustion or
statement timeout on that single INSERT produced a committed post with
nsfwLevel = 0 forever: a permanent 404 to non-owners, absent from the
profile tab and every feed, with no reconciliation sweep (the temp
backfill covers ModelVersion/Model only) and no alert. The trigger it
stands in for fires INSIDE the publishing transaction, so the parity was
never exact.

It is now issued on the transaction client, as the trigger's own
statement, ON CONFLICT DO NOTHING included. The effects function's
docblock says why nothing visibility-deciding may live there: every
effect it issues must survive being silently dropped.

Tested at the symptom rather than the call: the publish ALONE, with the
post-commit path never invoked, must still leave the post readable by the
two non-owner predicates; and it must still do so when every post-commit
statement fails and the router swallows it. A "was enqueueJobs called"
assertion passes in both designs, which is why it was replaced. Plus the
structural half — the insert lands on the transaction client, not the
global one — and the rollback arm.

Also in this pass:

- The trigger enumeration claimed TWO Post triggers and that INSERT was
  in neither event list. There are FOUR, and one of them is in a
  migration rather than programmability, so a sweep of programmability
  alone gets this wrong. post_published_at_change is a third UPDATE-only
  one; it is not re-issued because its effect already happens by accident
  — image_sort_at_before fires on the adopt, which runs after post.create
  in the same transaction. That coverage is incidental and fragile, so
  its ordering half is now pinned by a test and its SQL half is marked
  unverified. The fourth, trg_moderation_post, DOES fire on INSERT, so
  the block-supplied copy is queued for moderation with no help from us.

- A workflow source that named NO indexes over outputs with one
  off-allowlist slot refused the entire post. The refusal exists because
  skipping an index the block NAMED would publish a different image than
  the one named; an omitted imageIndexes names nothing and is documented
  as "every available output". It now skips the blanked slot there and
  refuses only an explicitly named one.

- That made the all-blanked guard load-bearing rather than redundant, and
  it is now pinned: deleting it changes the refusal to two different
  messages that point an author at the wrong problem. Measured before:
  deleting it left the file green, because the mutant died to the
  downstream guard's identical message.

- The ClickHouse nsfw flag reduced over published members only, so a
  MIXED post reported SFW permanently even after its fresh output scanned
  X. A fresh member now unrates the whole post, matching the all-fresh
  arm's conservative direction.

- Two claims narrowed to what they actually cover: the effect ledger
  records only calls through the mocked modules, and the Tag.name
  no-format-characters premise is an unverified empirical assumption
  whose character class includes ZWJ/ZWNJ.

* docs(apps): correct five round-3 audit findings in the post-from-app comments

Comment and test-assertion corrections only. No behaviour change: the diffs in
both source files are comment-only, verified mechanically.

1. The SQL offered for settling the Tag.name premise did not run. PostgreSQL's
   regex engine has no \p{...} property classes, so the suggested
   `name ~ '[\p{Cf}]'` fails with "invalid regular expression: invalid escape \
   sequence" — leaving an operator with a syntax error and the assumption still
   unverified, which is the one thing that note exists to prevent. Replaced with
   an explicit enumeration of the full Cf set written in ARE \uXXXX / \UXXXXXXXX
   escapes, so the query also carries no invisible characters of its own, plus a
   line saying why it enumerates so nobody simplifies it back. Verified on a
   throwaway PostgreSQL 17.10 with both controls. The \p{Cf} in the code is
   JavaScript's engine and is correct; it is untouched.

2. The cap docblock claimed a refusal rather than a truncation unconditionally.
   The maxCount +1 trick is what makes an over-cap request visible, but the skip
   arm for an unnamed index on a blanked slot consumes a selected slot without
   contributing, absorbing that headroom. Docblock corrected to state the
   exception; behaviour deliberately left alone, since the preview runs the same
   resolver so the published set still matches the consented thumbnails.

3. The ON CONFLICT assertion targeted calls[0] positionally, two lines after the
   same test computed a content-based selector. Now selects the statement by its
   INSERT INTO "JobQueue" token, with a length check as a positive control on the
   selector itself.

4. "byte-for-byte the one create_job_queue_record runs" was false — the trigger's
   VALUES clause has no ::integer and different value sources. The statement is
   character-identical to enqueueJobs' per-row SQL; cited that instead, and
   stated the parity that actually matters (identical bare conflict target
   against the JobQueue primary key).

5. The trigger ledger is complete for this repo, and the repo is not a complete
   record. bitdex_post_54f0a619 appears only as a DROP on "Post", with no CREATE
   anywhere in the tree, so this table has already carried a trigger the
   enumeration method structurally could not see. Named pg_trigger on the
   production database as the authority and gave the query, so "re-derive" is
   actionable.

Also fixed a duplicated clause left by an incomplete edit in the
publish_post_metrics_trigger comment.

Gates: typecheck 0 errors; test:lint-rules 38 files / 530 tests; the post,
blocks.router and AppBlocks suites 71 files / 1450 tests; prettier clean (both
checks run against a validated instrument).

Mutation for 3: deleting ON CONFLICT DO NOTHING from the JobQueue insert fails
the new assertion at its own line with its own message, after the positive
control passes. Adding an earlier raw statement that also carries the phrase
while stripping it from the JobQueue insert PASSES 12/12 under the old positional
assertion and FAILS under the new one — the regression this change prevents,
demonstrated.

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-14 10:58:01 -05:00
briant 2f721c0f94 feat(generation): generator messages, selectable disabled gates, experimental alert fix
- Fix: the form-graph generator never mounted ExperimentalRulesSync, so the
  experimental flask and alert never rendered there.
- Gate and experimental alerts are no longer dismissible and render below
  the model selector instead of in the footer's priority-alert slot.
- A rule-disabled ecosystem, workflow or model version stays selectable.
  The whatIf request and generate button are blocked, the graph still
  refuses disabled ecosystems/workflows, and validateInput refuses a
  disabled model version.
- getGateRules parses rules one at a time, so a single unreadable rule can
  no longer drop every gate rule on an older build.
- Generator messages: a separate store (Redis generation:messages) for
  mod-authored copy above the submit row, targeted by ecosystem, workflow
  or model version and by audience (members, non-members, tiers), with an
  optional per-message dismissal that re-shows when the copy is edited.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-14 09:57:45 -06:00
Zachary Lowden 2803e825b1 feat(app-blocks): give sensei a full-bleed exemption from the page-width cap (#4804)
* feat(app-blocks): give `sensei` a full-bleed exemption from the page-width cap

Adds `sensei` as the second member of the FULL-BLEED OPT-OUT LEDGER in
`src/styles/globals.css`, keyed on `[data-app-page-frame][data-block-id='sensei']`
— the same selector shape the `playable-collections` rule uses after its
production fix, NOT the `data-testid` spelling that `next.config.mjs` compiles out
of the live DOM.

This is a PRODUCT DECISION by the repo owner, not a bug fix. Nothing about the cap
is malfunctioning for this app; sensei is one of the two apps the cap's own census
was written about, and the trade is being taken the other way for it. Notepad, the
sibling case in that same census line, is deliberately NOT changed here.

The membership assertion was watched fail before it was updated: with the rule in
and the expectation untouched, `pageBlockHostMaxWidth.test.ts` went red naming the
set delta (`+ "sensei"`, expected `['playable-collections']`), which is the
designed workflow for this ledger.

Also corrects the ledger's membership claim in every place it was restated, which
turned out to be three rather than two:

  · `PageBlockHost.tsx` still asserted "NO LEDGER ENTRY IS WRITTEN TODAY, and the
    ledger's expected set … is `[]`". The test's real expectation was already
    `['playable-collections']`, so the comment was the stale side and had been
    false since that entry landed. It no longer restates the membership at all.
  · `globals.css`'s ledger header said "One member today". It now names no count —
    the rules below it are the authority.
  · `PageBlockHostMaxWidth.browser.test.tsx` said "THE LEDGER'S ONE REAL MEMBER"
    and named `playable-collections` in its own title. Its green arm is now DERIVED
    from the rules it already parses out of `globals.css`, so every member is
    measured and no count is stated; the enumeration that must fail on growth AND
    shrink stays in the node tier, which is a different claim.

The publisher-facing HOW-TO in `docs/features/app-blocks.md` gains the entry with
its reason and likewise drops the count.

Verified (both vitest tiers read, per the two-tier rule):
  · node `unit` — `pageBlockHostMaxWidth.test.ts` + `ledgerSelectorSurvivesProdStrip.test.ts`
    16/16 green; red→green on the membership assertion shown above.
  · browser `component` — `PageBlockHostMaxWidth.browser.test.tsx` 11/11 green,
    and the new derived arm was mutation-tested: setting only the sensei rule's
    value to `1500px` failed with THIS arm's own message ("the app 'sensei' is NOT
    full-bleed", 1500 vs 2560) while the other 10 tests stayed green.
  · `pnpm run typecheck` 0 errors; `prettier:check` clean; eslint on the touched
    TS files 0 errors (1 pre-existing unrelated hooks warning); `test:lint-rules`
    510/510.

NOT verified: a green browser tier is specifically NOT evidence the selector works
on civitai.com — vitest never runs with `NODE_ENV=production`, so the
`reactRemoveProperties` strip never applies, which is exactly how the `data-testid`
spelling shipped broken with that suite passing. The production-safety claim rests
on `ledgerSelectorSurvivesProdStrip.test.ts` (which compares the two
configurations) and on mirroring the already-fixed rule's shape. A CSS rule's real
proof is a rendered page on a wide viewport, which was not reached.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKwVejr8dHqb8nydsfqHk3

* docs(app-blocks): make the full-bleed ledger's bar admit its second member by the rule

The ledger's admission criterion excluded the entry the same PR added. It read
"an app whose surface is the canvas … NOT for 'it looks bigger'", and the sensei
entry openly does not clear it — it says the cap binds exactly as designed and
the owner decided the trade should go the other way. A bar the second of two
members openly fails, with nothing amended, is decoration: the hazard is the
THIRD entry, which now has precedent for "the owner said so" as a ground the
written criterion still excludes.

So the criterion now names the category actually used. Two grounds, and an entry
must say which it claims: (1) the surface is the canvas, unchanged; (2) an
explicit product decision by the repo owner, admissible only if it records who
decided, what trade was accepted, and what it is worth as a measured
display-width class. The "NOT for 'it looks bigger'" exclusion is KEPT and is
what ground (2) is distinguished against — a preference has no author, no stated
cost and no measured reach, so the three bullets refuse it by the same sentence.
Ground (2) does not waive ground (1) generally; it admits the entry that shows
its cost, one at a time. The sensei entry then records those three things, with
the record kept to what is actually on it and no extrapolation of the owner's
reasoning.

The entry's other half was an unasserted cross-repo reading that disclaimed
itself — "no ref recorded, no fixture reproducing it … the sensei repo was not
re-read for this entry" — while member 1 one slot up carries a deployed ref and
five file:line citations. Same slot, opposite evidentiary standard. The reading
has now been taken and the census HOLDS, so the citations replace the
disclaimer, matching member 1's convention: root shell, main row, the fixed
240px sidebar, the chat pane, and the absence of any max-width on the transcript
path, each at its own file:line. Recorded as `trunk 6fd63c0` and explicitly
flagged as a BRANCH TIP rather than a resolved deployed ref, because none was
resolved for sensei — claiming a deployed ref that was never resolved is the
error this flag exists to prevent.

Two deletions, both reviewability:

  - `PageBlockHost.tsx` quoted the old false sentence verbatim, declared "THE
    MEMBERSHIP IS DELIBERATELY NOT RESTATED HERE", and then restated the
    membership five lines later. Both paragraphs collapse to one line; cutting
    the second is what makes the first's stated policy true. `git log -p` holds
    the old sentence and the enumeration test already fails on growth and shrink.
  - `docs/features/app-blocks.md` had swapped a rotting COUNT for a rotting
    LIST. Nothing asserts the doc's member list matches `globals.css` — the
    prod-strip guard pins the doc's selector SHAPE only, never its membership —
    so it reduces to one sentence pointing at the ledger, which the doc already
    calls the authority.

No selector and no assertion logic changed: comments, one docs paragraph and the
criterion only. Verified on both tiers in an installed worktree (Round 0 could
run neither). Unit — `scripts/test-unit-run.mjs`, 16 tests across
`pageBlockHostMaxWidth.test.ts` + `ledgerSelectorSurvivesProdStrip.test.ts`,
green before and after. Component — `scripts/test-component-run.mjs`, 11 tests
in `PageBlockHostMaxWidth.browser.test.tsx`, green before and after, with the
membership and opt-out render arms unmoved. `typecheck` clean.

The generic prod-strip guard was confirmed to cover the new rule by mutation
rather than by reading: keying the sensei rule on `data-testid` turns exactly one
test red — "no shipped ledger rule depends on an attribute production strips" —
with that guard's own message and a payload naming the sensei selector, while the
membership assertion stays green. That guard came from PR #4590, not this PR.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKwVejr8dHqb8nydsfqHk3

* fix(app-blocks): pin the ledger walk against the file, and retract a criterion claim that does not hold

Round 1's fix round traded away a mutation class, and the ledger's admission
criterion claimed more than it can enforce. Four findings, no runtime change —
one test assertion, comments, and one docs word.

F1 — the derived green arm could miss a member without failing.
`ledgerFromGlobals` walks the CSSOM and descends into `CSSLayerBlockRule` only,
so a member rule nested in `@media`/`@supports`/`@container` drops out of
`ledger.ids`. The rewritten control only asserted the list was non-empty, and a
count cannot see a PARTIAL loss: the OTHER member keeps it non-empty, the loop
never mounts the lost one, and the test passes. Measured, not theorised —
wrapping the sensei rule in `@media (min-width: 3000px)` left this file 11/11
and the two node-tier guard files 16/16 while sensei rendered capped at 1600 on
a 2560 display. Neither of the other guards can see it: both read raw text,
where the id is still present.

So the relationship is pinned instead — the ids the CSSOM walk could REACH must
equal the ids the file textually contains, comments stripped. That keeps the
"no membership restated here" property (both sides derive from the shipped file)
and fails the moment a rule moves somewhere the walk cannot reach. Watched red
under the `@media` wrap at 1 failed / 10 by its own message, naming the delta
`- "sensei"`; green with the rule at the top level. The id regex is now shared
by both parses so the comparison can only ever be about reachability, never
about quoting.

F2 — the admission criterion does not refuse "it looks bigger", and said it did.
Ground (2)'s three bullets were claimed to exclude a bare preference because a
preference has "no author, no stated cost and no measured reach". That inference
does not hold: every request has a requester, WHAT TRADE is the same ~150px
gutter arithmetic for every app (a property of the 1600 cap, not of the app),
and WHAT IT IS WORTH measures the reach of the EXEMPTION, so two different apps
get a literally identical answer. The entry admitted under it proves the point —
it records that no app-specific justification exists and clears the bar anyway.

The strict repair would make sensei's own entry inadmissible, so the claim is
RETRACTED rather than replaced by a stronger one: ground (2) is an owner
override, the bullets make an entry recorded and reviewable rather than
justified, and the `NOT for "it looks bigger"` exclusion is moved onto ground
(1), where there is a claim about the surface to be wrong about. The shape to
watch for — a run of entries each copied from the last — is named, with the
honest alternative (move or drop the 1600 default) stated. The sensei entry now
says outright that it is not a template. Two sub-points with it: the WHO bullet
accepts a role, since this repo is world-readable and a role is what is actually
applied; and "this changes nothing at all for where the traffic is" is marked as
a claim about display WIDTHS, not about a population this repo measures nowhere.

F3 — the cap's value-justification still named Sensei as a case it governs.
After this branch Sensei gets the viewport, not ~1350px. The paragraph now
describes the two-pane SHAPE the value is justified against and names no app,
which is also the only phrasing that does not restate membership in a file that
deliberately refuses to.

F4 — docs: "the two grounds" is the count the same sentence says is not mirrored.

Re-verified unchanged after the F1 rewrite: the membership assertion still fails
in both directions (expectation reverted -> `+ "sensei"`; CSS rule deleted ->
`- "sensei"`, browser tier correctly still green there, since the shrink claim
is the node tier's), the derived arm's `1500px` mutation still dies with its own
message at 1 failed / 10, and the prod-strip mutation still isolates at
1 failed / 15.

node `unit` 16/16 (scripts/test-unit-run.mjs, the two guard files), browser
`component` 11/11 (scripts/test-component-run.mjs), scripts/typecheck.mjs 0
errors, prettier:check clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WKwVejr8dHqb8nydsfqHk3

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-13 13:56:28 -05:00
Zachary Lowden c56e46ab90 feat(moderator): a /feedback triage surface for the Feedback table (#4785)
* feat(moderator): a /feedback triage surface for the Feedback table

The onsite feedback prompts have been writing to a table nothing reads. Three of
the four statuses its CHECK constraint allows were unreachable by any code path
that existed. This is the reader.

- `/feedback`: one page, status + area filters in the URL, keyset paging, and an
  inline `?open=<id>` detail panel.
- Context is rendered as a CLAIM, not evidence. `context.path` is a bare
  pathname, so the panel rebuilds the URL from `path` + `filters` (omitting the
  store defaults) — `path` alone links to a different page than the one the
  report is about.
- The Faro session id becomes a Grafana Explore link, and only while the rows
  still exist: past the 72 h Loki retention there is no link and an explicit
  expiry note, because "no logs found", "this session produced no telemetry" and
  "the link is broken" are three facts with one observable.
- Two permissions, `feedback.status.set` and `feedback.bug.promote`, composed
  with the page grant rather than welded to it.
- Promoting mints a `Bug` with `publishedAt` NULL and `content` NULL, so a
  reporter's words cannot reach the public board and no unsanitised text reaches
  an HTML-rendered field.

Both writes are scoped on the state the operator was looking at — the triage
UPDATE on the status they saw, the link on `bugId IS NULL` — and zero affected
rows is a 409, never a success.

The `20260911120000_feedback_triage` migration is merged but NOT APPLIED to any
environment; a human applies it per environment as every migration here is.
This commit is the `schema.full.prisma` model edit plus the regenerated files.

* fix(moderator): resolve the /feedback review findings

Three review agents over the segment, then two delta audits over the fixes.
The defects, in the order they cost an operator something:

- Attachment ids were rendered through `getEdgeUrl`, which returns its argument
  VERBATIM when it starts with `http`/`blob` — and the producer bounds them by
  length only. A reporter could point a moderator's browser at any origin and
  collect a read receipt naming who opened their report. `splitContext` now
  requires a Cloudflare-key shape; a rejected value is shown as text rather than
  dropped.
- A duplicate id in that same client-supplied array THROWS `each_key_duplicate`
  in production, making a report permanently unopenable. Deduplicated.
- The triage note was blanked on every save and the next click destroyed it.
  `update()` resets the form before `invalidateAll`, and Svelte does not rewrite
  a bound value that has not changed — so the box sat empty over a column that
  still held text. `FormState` grew a `reset` option; the default is unchanged
  for its other 37 call sites.
- Two refusals had no surface at all: the panel is unmounted before the message
  is assigned whenever the row leaves the view, and a no-JS submit has no panel
  to begin with. A page-level alert now covers exactly the cases the panel
  cannot, and both panel alerts sit outside the branch that the reload flips.
- "Attach to an existing issue" 404'd on an issue created seconds earlier — the
  existence check read the replica. It reads the primary, as does the check that
  decides whether a refusal says "already linked" or "that report is gone".
- An empty attach form refused with "Give the issue a title" over a form showing
  no title field. The mode is posted explicitly instead of inferred.
- The canonicalising redirect fired on the action POST too, so a no-JS client
  would re-run the action and be told a save that worked had conflicted.

Also: `//host` rejected in the reconstructed URL, the keyset cursor parsed once
in the query schema rather than twice across two layers, `MAX_INT4`/`isInt4Id`
and `clearPaging` taken from their canonical homes, `issuesUrl` given one
definition, the sibling row type exported rather than hand-copied, `$bindable`
filter controls moved to function bindings, and the page split into a filter
bar, a detail panel, a context panel and a promote panel.

Tests: 33 added over the round, including a `load` suite that pins the
GET-only redirect and the cursor bound, and a PGlite case for the promote
path's rollback.

* fix(moderator): blind-audit findings on the /feedback queue

Three comments asserted a mechanism SvelteKit does not have. They said a
refusal re-runs `load` before `FormState` assigns its error, unmounting the
panel. In `@sveltejs/kit@2.66.0` both the reset and `invalidateAll()` sit inside
`if (result.type === 'success')` (`runtime/app/forms.js:99-107`), so no reload
happens on any refusal. The behaviour was right and the reason was invented —
and it is the sentence a maintainer would use to delete the panel-level alert as
redundant, which is exactly the defect an earlier round introduced. Corrected to
what is true: the page-level alert is the NO-JS surface, the panel alert is the
JS one, and the two cover disjoint paths. Where hoisting those alerts out of
their branch chains no longer has a reason, they now say so rather than carrying
a replacement rationale.

- The keyset cursor was untested at any page size but one, so the mutant
  returning `items[0].id` instead of the last row's SURVIVED a green run —
  at `FEEDBACK_PAGE_SIZE` 50 that repeats 49 rows and strands 49. The fixture
  now seeds three rows and pages at two, where the first and last of a page
  differ, and the mutant dies on `expected 3 to be 2`.
- The page 500'd for the one role that could already reach it. `allows()`
  short-circuits for `moderator:admin`, so the nav entry and its badge are live
  before any `/admin` tick, and the badge counts on `status` alone — a real
  number against an unmigrated database, then 42703 out of `load` on every
  click. It now catches that one code and says which migration to apply. The
  migration header claimed the opposite and is corrected; it is the sentence
  someone would pick a deploy order from.
- `handledById` NULL rendered two different wrong ways: the detail view printed
  "Handled by #null" for a deleted account, and the list called a live account
  with no username "deleted account". Two independent nullables, one helper.
- A row reopened to `new` kept its `bugId`, which put it in the unhandled queue
  showing the linked-issue panel instead of the promote form — and
  `linkInTransaction` refuses any row whose `bugId` is set, with no unlink
  control anywhere. The link is now cleared alongside the handler columns, for
  the same reason: `new` means untriaged.

Also: `reset: false` on the promote form, whose hidden `id`/`mode` inputs a
reset would blank; sibling links drop the keyset cursor so they cannot land on
"not in this view"; the copy button returns to idle.

Two things are recorded rather than fixed. The list read stays on the replica —
it fails closed and matches every other queue here — with the cost written down
at the call site. And the PGlite tier binds `dbRead` and `dbWrite` to one
client, so no test pins which of them a call site uses; that is now stated in
the suite instead of being implied by a green run.

* fix(moderator): stop clearing bugId, and narrow the 42703 degrade

Reverts the `bugId` clearing added last round, and corrects a claim that had
been repeated three times without anyone tracing it.

THE REVERT. Clearing the issue link when a row returns to `new` traded one bad
state for a worse one. `handledById`/`handledAt` record WHO ACTED, which a
reopen retracts; the link records that this report is ABOUT that issue, which a
reopen does not make false. Clearing it destroyed that with no way back — the
number is stored nowhere else, `ModActivity` has no column to put it in, and
`getSiblingFeedback` silently dropped the row from every sibling's list — and
left a `Bug` with nothing pointing at it, the state `promoteFeedbackToBug` runs
a transaction rollback rather than create. Two comments in one file disagreed
about whether that matters; the existing rule wins.

What that leaves open is recorded rather than papered over: a linked row can
never be re-linked, because the promote path requires `bugId IS NULL`. That is a
missing unlink control, it is missing at every status, and reopening is not a
sensible back door to it. Not added here. `docs/moderator-app/` §5 now states
the rule and the gap, so the doc and the code agree.

THE FALSE CLAIM. A test comment said the surviving cursor mutant "makes 49
unreachable". It does not. Walked against the harness: seven rows at `limit: 3`
page 7-6-5 / 6-5-4 / 5-4-3 / 4-3-2 / 3-2-1 — every id still reached, each turn
repeating all but one row. The cost is a queue that drains 50× slower, not one
with holes in it. The fixture's justification is unchanged, which is the point.

Also: `isMissingTriageColumns` now requires the message to name one of the four
columns that migration adds, not merely the `42703` code. The page answers with
one specific instruction, and `42703` is raised by any absent column — so the
code alone would give that instruction to an unrelated typo while the `catch`
suppressed the error that would have named it. Matched on the column name rather
than on "does not exist", which does not survive `lc_messages`.

And the degrade is now exercised end to end: a new pre-migration PGlite fixture
runs the real query against the real pre-migration table and asserts on whatever
the driver actually raises, rather than on a hand-built `{ code }`. Measured:
`code` is top level and the message is `column f.handledById does not exist`.

* docs(moderator): record the frozen-link gap at the guard that enforces it

Round 3 of the audit ladder: the note explaining why a linked row can
never be re-linked sat inside triageFeedback, 150 lines from
linkInTransaction's `bugId IS NULL` clause -- which is what a
contributor adding an unlink control would actually open. Cross-referenced,
and the ON DELETE SET NULL cascade named so it is not mistaken for a control.

Also replaces a count in a test comment with "every other", after the
count drifted when this round added a case. Fix the form, not the number.

Comment-only: no executable line changes.

* docs(moderator): qualify the error-shaped claim, and drop a wrong lesson

Round 4: "Every OTHER case constructs { code, message }" was false -- the
last case in that block deliberately feeds non-error values. Qualified, and
the qualifier is now called out as load-bearing so it is not trimmed later.

Also drops the parenthetical claiming the earlier count had drifted. It had
not: traced the block across the three fix commits -- 3 cases at 9416bfd5,
5 at b58b8887, where the comment said "both other" while there were already
four others. The count was wrong when written, not later. Removed rather than
replaced with a third theory about it.

Comment-only: no executable line changes.
2026-09-11 23:54:54 -05:00
Zachary Lowden 6cce959489 docs(moderator): scope a feedback triage surface for the Feedback table (#4777)
* docs(moderator): scope a feedback triage surface, and ship its migration

The onsite feedback prompt on /apps has been writing to a table nothing reads.
All 26 rows in Feedback are status='new' (3 apps-marketplace, 23 orphaned
bitdex-image-feed), and three of the four statuses its CHECK constraint permits
are unreachable by any code path that exists: there is no tRPC read procedure, no
moderator page and no consumer anywhere.

This is a scoping proposal for the read surface, not an implementation. No .svelte
file and no +page.server.ts is added; the migration is the only executable artefact.

  docs/moderator-app/feedback-triage-proposal-2026-09-11.md
    One /feedback page in apps/moderator, all areas with an area filter (so the
    orphaned bitdex rows stay reachable), status + moderator-internal triage note,
    and a promote-to-Bug path that reuses the existing inbound ClickUp webhook.
    Two new permission ids, feedback.status.set and feedback.bug.promote, held
    apart for the reason audit.ban.execute is; page access stays a NAVIGATION
    grant and gets no permission of its own. Attachments render inline as
    thumbnails, with the unverified-client-supplied-id risk recorded as accepted.

  packages/civitai-db-schema/prisma/migrations/20260911120000_feedback_triage/
    Additive and idempotent: triageNote, handledById, handledAt, bugId, plus a
    (status, createdAt DESC) index. Manual-apply, like every migration here.

Two corrections to the brief this was written from, both verified in the tree:
packages/civitai-db-schema/prisma/schema.prisma does not exist and is gitignored
output of generate-slim-schema.js -- the authored model is in schema.full.prisma;
and appsStoreFeedbackContext.ts does not emit sessionId, FeedbackPrompt merges it
at submit. The matching schema.full.prisma edit is stated in the doc but
deliberately not applied here, because landing it requires pnpm run db:generate
and committing the regenerated packages/civitai-db-schema/src to keep the
db:check-generated CI gate green -- work that belongs on the implementation PR.

* docs(moderator): fold the three answered questions in as decisions

Replaces the proposal's "Open questions for the operator" with a Decisions
subsection. All three are settled, not deferred.

1. No reporter feedback loop. Feedback.bugId is an internal link only; nothing
   notifies the reporter, now or as a planned phase. "Notifying reporters" stays
   in Not-in-scope but as a decision rather than a likely next ask.

2. feedback.bug.promote launches with the same roles as feedback.status.set --
   a promoted Bug lands with publishedAt NULL, and publishing is a separate act
   behind bugsEdit on /issues. The two ids stay separate deliberately: narrowing
   the grant later is then one tick on /admin rather than a permission rename,
   which would orphan stored grant rows.

3. Build the Grafana Explore deep link (this overrides the recommendation to
   leave sessionId as a copyable string).

The deep link, designed in section 3:

  Explore "panes" state against Loki, uid "loki" -- pinned in provisioning and
  read out of /etc/grafana/provisioning/datasources/datasources.yaml in the
  running Grafana pod, not inferred from a manifest. Grafana is 13.1.1, so
  "panes" is the current form and the legacy ?left= parameter is not built on.
  Range is derived from Feedback.createdAt, plus/minus 1h, not now-72h.

  The filter is the raw substring |= "<id>", NOT | logfmt | session_id="<id>":
  the id appears under two spellings in one stream -- session_id= on
  kind=event lines, event_data_session.id= on faro.tracing.fetch lines -- so a
  logfmt filter silently drops exactly the rows carrying traceID/spanID. The
  substring match is not provably collision-free on a ~10-char opaque id; that
  is stated rather than hidden.

Retention changes the feature's shape, so it gets its own treatment:

  Loki's global retention_period is 72h and {source="faro-rum"} has no stream
  override. Past that the Explore view returns no rows, and "the data expired",
  "this session produced no telemetry" and "the link is broken" are three facts
  with one observable. So the link is age-aware: inside 72h a live link, outside
  it no link plus an explicit expiry note, cutoff derived from createdAt and
  held in one named constant (FARO_LOKI_RETENTION_HOURS) whose comment names
  Loki's limits_config as its source. That constant is a copy of a number owned
  by another repo and nothing keeps the two in step -- recorded as a risk.

  Consequence stated plainly: the link's value is bounded by triage latency. It
  pays off only if the queue is read within three days, and this queue has gone
  unread for a month, so all three live apps-marketplace rows already render as
  expired. That is an argument for the sidebar count, not against the link.

Also: a faroSessionLink test (both wrong answers are silent -- take `now` as an
argument, pin the boundary, assert the URL contains the substring filter and not
logfmt), a line in the effort estimate, and a note that the built URL must be
clicked once against a report under 72h old, since a URL built from
documentation is a claim about the documentation.

No change to the migration.

* docs(moderator): redact internal infra details from the proposal

civitai/civitai is a PUBLIC repo. Two details in this doc appear nowhere
else in it and should not be published:

- the prod database pod name, in the measurement attribution
- the internal Grafana origin, twice in the deep-link section

The Grafana origin was already designed to arrive via a new
PUBLIC_GRAFANA_URL variable, so the doc now refers to the variable and
points at the infra repo for its value -- which is where a deployment
origin belongs regardless of who can read this file.

"prod nvme0" is deliberately left alone: 19 existing migrations in this
repo already use it, so it is established convention here, and diverging
would make this migration inconsistent with its siblings.
2026-09-11 18:41:11 -05:00
Luis Rojas 12d3d582f8 Merge branch 'main' into feat/training-studio-app 2026-09-11 18:59:56 -04:00
briant 0b92ab649c docs: drop package and component counts that had gone stale
`@civitai/ui` gaining a vitest config took the packages/* suites to 13, and the primitives under
components/ui/ are at 54 — while the prose still said nine and 24. The counts are removed rather
than corrected: CI's ledger script already asserts every workspace suite ran, so a number in prose
only rots, and both of these had rotted twice.

The schema-drift README also said apps/* had no CI job and that the ledger script hardcoded
`packages/`. Both stopped being true when the App unit tests + typecheck job landed; it takes the
workspace as an argument.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 16:21:11 -06:00
Briant Diehl 1d08240d04 Merge pull request #4779 from civitai/feat/moderator-multi-check
feat(moderator): shift-click range selection via a shared SelectionSet in @civitai/ui
2026-09-11 16:09:48 -06:00
briant bf54258e2e feat(moderator): shift-click range selection via a shared SelectionSet in @civitai/ui
Every image grid in the moderator app (the review queues, image tags, stuck ingestion, bulk image
manager, and the user- and post-report panels) now supports Gmail-style shift-click: the clicked
card toggles, and every card from the last-clicked one to it takes that same new state.

@civitai/ui:
- hooks/selection-set.svelte.ts: SelectionSet, a SvelteSet subclass holding the anchor. Being a
  SvelteSet, the components that already take one need no changes; clear() also drops the anchor,
  so every existing clear() call site resets it.
- components/selection/: SelectionCheckbox, the shared Checkbox wired for shift-click with a
  function binding (a plain checked= prop latches on bits-ui's own write). It records shiftKey in
  onclick/onkeydown, which bits-ui runs before its toggle, so Space works as well as a click. Kept
  outside components/ui/, which shadcn --overwrite regenerates.
- The package's first test suite: vitest.config.ts resolves svelte/reactivity with the browser
  condition under ssr.resolve, so the tests run against the reactive SvelteSet rather than the
  server build's plain Set. A guard test fails if that ever regresses.

Moderator app:
- ImageQueueGrid uses SelectionCheckbox for its corner checkbox, restyled to stay visible over
  images, and passes the on-screen order to both it and the whole-card button.
- The six pages that build a grid selection construct a SelectionSet.

Docs: the Svelte app standard, the svelte-idiom-review agent and the @civitai/ui README now point at
all of components/ (hand-written components live beside ui/, which --overwrite regenerates), and the
root vitest config no longer states package-suite counts that went stale.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 16:08:27 -06:00
briant 6fec1ced87 feat(skills): generator model onboarding skills and doc
Replaces the stale adding-new-base-models-to-generation doc, which described
code that no longer exists, with docs/features/generator-model-onboarding.md,
and adds the skills that run that flow:

- onboard-generator-model: orchestrates the child skills and the deploy stops
- official-model-admin: Draft CivitaiOfficial model and version, API-only vs
  hosted-weights detection and the file-upload check, and description writes
  gated on the user's approval of the exact text
- generation-coverage: EcosystemCheckpoints rows plus the cache bust
- generation-gate-rules: "available to moderators, hidden" gate rules
- generator-launch: readiness check and the post-deploy publish-then-ungate steps

add-generation-support now covers the form-graph lane, and add-ecosystem defers
generation support when the orchestrator runs it. mod-actions' trpcCall no longer
sends input={} for input-less queries, which the API rejects with a 400, and
gains the shared CLI helpers the new scripts use.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 15:47:48 -06:00
Luis Rojas d6faa58910 Merge branch 'main' into feat/training-studio-app 2026-09-11 16:27:40 -04:00
Luis Rojas 94dbd2d3ef feat(training-studio): extract the app into the <civitai-training-studio> web component
The flow now lives entirely inside a custom element both hosts render identically
(docs/training-studio-web-component.md):

- Host seam ($lib/host): backend/config/hrefFor/navigate/refresh + StudioLocation — flow code
  never touches $app/$env or a URL path; the SvelteKit shell wires it in +layout.svelte, the
  element wrapper wires it from host-injected props.
- StudioBackend seam: shell-backend keeps the /api routes; element/backend calls the
  orchestrator browser-direct with a host-minted token (401 → re-mint once). Pure builders/
  mappers moved to client-safe cores (train/pricing/autolabel/orchestrator-core); the server
  modules are thin env/token wrappers over them. Batch-submit policy, the 20-epoch clamp, wire
  types and ProblemDetails rendering each live once.
- Views: RunDetail extracted from [id]/+page.svelte; StudioApp renders home/new/run off a
  host-controlled `location`; dataset blobs go through backend.datasetBlob (authed fetch →
  object URLs in the element); anchors navigate via the locationHref action (SPA in both hosts).
- Element build (vite.element.config.ts): every selector scoped under the tag and dual-scoped
  to a [data-cts-portal] body-level portal root (dialogs/selects/tooltips portal there —
  escaping an embedding page's container-type containing block — and stay styled); @layer
  unwrapped so host utilities can't override scoped rules; translate rules get transform:none
  against Tailwind v3/v4 double-shift; registration is idempotent.
- Native light mode: a `light` class on the element flips the palette (.dark gates compile to
  :not(.light); the fixed dark-scale remaps role-preservingly; text-on-accent stays white on
  colored surfaces). Root background transparent — the host shows through.
- Live NDJSON tracing defaults on (events); TRAINING_TRACE_MODE is the kill switch.

Reviewed via /svelte-review (three lanes), a main-app correctness lane, and a browser QA sweep;
all findings applied. Verified live in both hosts: views, uploads, auto-label, thumbnails,
dialogs centered, light/dark, SPA nav.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-11 16:00:33 -04:00
Briant Diehl d8a2fcf534 Merge pull request #4773 from civitai/feat/image-scan-stuck-alert
feat(image-scan): stuck-scan alert signal and a moderator rescan for stranded images
2026-09-11 13:10:11 -06:00
briant 52c7351fe0 feat(image-scan): stuck-scan alert signal and a moderator rescan for stranded images
An image whose content scan never returns stays at ingestion = 'Pending' indefinitely: invisible to
everyone else, with no error, no metric and nothing to alert on. A write-back defect in early
September stranded tens of thousands this way and was found from support tickets, not monitoring.

Main app (src/server/prom/client.ts):
- image_ingestion_stuck_pending{type}: Pending images created 15m-24h ago, per media type and
  zero-filled. The alert signal. The existing Pending oldest-age gauge is pinned flat by rows
  stranded months ago, so it could never have fired.
- image_scan_queue_depth / image_scan_queue_oldest_age_seconds: the ImageScan JobQueue, uncapped
  (the cron's own depth gauge is the length of a take-limited read).
- image_ingestion_gauges_refreshed_timestamp_seconds: a failing refresh freezes every gauge at its
  last value, so the refresh's own freshness is published.
The stuck counts ride the existing Pending walk rather than a second query (+47 buffers measured on
the replica). A failed refresh now discards its pg client instead of pooling it.

Moderator app (/images/to-ingest):
- A stuck view with no age limit and a scan-pipeline panel; the sidebar badge counts stuck scans.
- A Rescan action, for selected images or the oldest 500. It moves still-stuck, unlocked images from
  Pending to Rescan and records mod activity per image; the ingestion trigger queues them and the
  ingest cron's Rescan lane sends them at low priority behind its cooldown and retry cap.
- The sidebar group badge now leaves out informational children, as the dashboard already did.

@civitai/shared/image-ingestion carries the 15-minute threshold, so the alert and the page that
triages it cannot disagree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 13:09:42 -06:00
briant 2c9957e149 feat(generation): require a SafeTensor weight file to load a checkpoint
The cluster serves SafeTensor only, so a checkpoint without one cannot be loaded —
and since paid loading is how a non-resident checkpoint generates at all, it cannot
generate either. `covered` must be false for it rather than "covered but never
offered a load", because the model detail page's Create button reads canGenerate.

Scoped to checkpoints, on the view's checkpoint disjunct rather than its shared
EXISTS. Loading is a checkpoint-only feature, and applying the rule to every type
would drop 3,287 covered textual inversions carrying 1.39 billion lifetime
generations — embeddings ship as PickleTensor and are not served by the loader.
`checkLoadable` is scoped the same way for the same reason.

Measured against the production replica, 2026-09-09: covered checkpoints
33,811 -> 31,569, total view rows 933,851 -> 931,609, every row of the delta a
checkpoint. 834 of the 2,242 have generation history (6.5M lifetime, 0.43% of
checkpoint generation); none is offered by the live GenerationCoverage and none
generated in the last month, so applying the migration ahead of any UI is safe.

An allow-list, not the deny-list the shared clause uses: `format` is free text and
frequently unset, so `<> ALL (...)` cannot promise SafeTensor while `= 'SafeTensor'`
is null-safe in the intended direction.

This reverses part of the 2026-09-08 Diffusers ruling for checkpoints only (174
versions); recorded in paid-model-loading-decisions.md as an open question for
Justin rather than left in a migration comment. CoveredCheckpoint returns as a
disjunct — not the conjunct it was — excusing 6 auction-resident checkpoints from
the requirement until the auction is retired.

Refusals now say which of the two reasons applies. The old copy told every
unloadable resource it "runs through an external provider", which is false for a
GGUF checkpoint; the moderator page hardcoded that same wrong string separately.

`no-divergent-safetensor-rule` pins the two halves together: nothing executes the
view (the suite mocks $queryRaw wholesale), so a text guard over the shared literals
and the checkpoint scoping is the enforceable shape. Mutation-probed three ways.

Migration is NOT auto-applied — run it manually where you want it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 16:43:14 -06:00
Luis Rojas b5c1d4c184 docs(training-studio): web-component extraction plan
The contract a host injects (token providers, buzz callback, config, navigate), the
browser-direct call surface derived from the 13 API routes, what stays host-side, the
confirmed CORS origins, and the three-stage migration (detach from Kit runtime → lift SDK
calls client-side → package as a custom element for the main app).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-09-10 15:42:26 -04:00
Zachary Lowden fd440a3049 feat(app-blocks): enforce scope grants + a per-app consent budget on the runtime (#4745)
* feat(app-blocks): enforce scope grants + a per-app consent budget on the runtime

The App Blocks runtime was gated by `assertViewerIsAppDeveloper` — an AUTHORING
capability — at 15 call sites covering generate, estimate, poll, cancel,
read-my-balance and read-my-viewer. The effect was that a viewer who is not an app
author could not USE an app at all. The platform now enforces the user's own SCOPE
GRANTS on those paths, plus a per-user/per-app daily Buzz budget set at consent time.

`await assertViewerIsAppDeveloper(userId)` in blocks.router.ts: 15 -> 1.
The survivor is `updateUserSettings`, which writes the install's persisted settings
(an authoring-shaped action no block scope expresses). It is a deliberate exception.

WHAT MOVED

- 10 procedures already had a `claims.scopes.includes(...)` check a few lines above
  the author gate; those lose the gate only.
- 4 sites had no scope check. `getMyBuzzBalance` gains `buzz:read:self`; the three
  helpers (estimateCustomComfyWorkflow, submitCustomComfyWorkflow,
  assertStepRequestAllowed) gain `ai:write:budgeted`. Only the first closes a
  reachable widening — the other three sit behind procedures that already reject a
  missing scope before dispatch, so they are defense-in-depth and are commented and
  reported as such rather than counted as coverage.
- The `isAppBlocksEnabled` kill-switch is untouched and still runs on every
  block-token procedure. It is now the ONLY identity-shaped belt on the runtime
  procs, so its fail-closed posture is no longer backed up by a second one.

CONSENT BUDGET

New nullable column `app_user_scope_grants.buzz_budget_per_day`, reserved at spend
time against a per-(user, app, UTC-day) Redis key alongside the existing platform
per-user daily cap. Both apply; the tighter binds. Skipped for dev tokens (self-bound,
synthetic appBlockId) and for run-for-real review tokens (which swap in their own
tighter ceiling). NULL budget is byte-identical to previous behaviour, so no backfill.

The correctness requirement: when the consent reservation rejects, the platform daily
reservation already taken is REFUNDED. Otherwise a denied attempt silently burns the
viewer's 50,000/day allowance. `reserveBlockBuzzSpendForClaims` now returns both legs
and every refund site calls `refundBlockBuzzReservation`, so the two cannot diverge.
The post-paid customComfy settle record carries the consent key too, so a ceiling
reservation converges on real accrued cost there as well.

Consent UI ships in the same change: a spend limit in BlockConsentModal, shown only
alongside `ai:write:budgeted`, off by default. An omitted budget leaves a stored one
untouched; an explicit null clears it. `listMyScopeGrants` surfaces the stored value.

DEPLOY ORDER: the migration is additive and nullable, so it is safe to apply AHEAD of
the rollout, but it MUST be applied BEFORE the new image rolls. It is not applied here
and no CI path applies it.

* style: prettier-format the files this branch touched

CI's "ESLint + Prettier (changed files)" is BLOCKING on ADDED files, and the new
`blocks.router.scopeEnforcement.test.ts` was not prettier-clean. Formatting the
four modified files alongside it clears the (non-blocking) warnings for them too.

No behaviour change: `pnpm typecheck` stays at 0 errors, and the four affected
suites plus the browser suite were re-run after formatting (453 + 7 passed).

One hunk in `scope-grant.service.ts` reformats a pre-existing line the formatter
wraps differently (`const incoming = Array.from(new Set(...))`) — cosmetic, and
inside a region this branch already edits.

* fix(app-blocks): read the primary when deciding a consent budget is meaningful

Adds the SEAM test the other two suites structurally could not provide, and fixes
the defect it found on its first run.

THE GAP. The enforcement tests mock the grant READ; the service tests mock the DB.
Both are hermetic, both pass whether or not the halves are wired together, and
neither ever builds the combined state — so nothing proved that a budget set through
`blocks.grantScopes` can actually reach `getConsentBuzzBudget` on the spend path.

THE DEFECT. `grantScopes` decides whether a budget is meaningful by asking whether
the app already holds `ai:write:budgeted`, and that read went to the REPLICA while
the write goes to the PRIMARY. A user who consents to the spend scope and then raises
their limit lands inside the replication window: the check answers "no spend scope",
and the budget they just set is silently dropped. Now reads with `db: 'write'`.

It presented exactly as it would in production — the SCOPES merged correctly (that
path already used the primary) while the budget alone went missing, which is the
shape that would have been reported as "my limit doesn't stick sometimes".

Six tests on `blocks.grantScopes`: persists with the scope, ignores without one
(without ERASING a stored value), honours a budget for an app that already holds the
scope (the regression above), omitted leaves a stored budget untouched, and the two
input-boundary rejections (above the platform cap, and zero).

* fix(app-blocks): survive a pre-migration deploy, unwind a partial consent reservation, make the budget editable

Adversarial-audit round 1 fixes.

1. A deploy landing before the migration 500'd every grant WRITE and every
   block generation (measured on this PR's own preview: P2022 on
   blocks.upsertSubscription -> recordInstallConsent). Prisma's default
   selection returns every scalar, so a create/update with no `select` emits
   RETURNING buzz_budget_per_day. All three write sites now pass an explicit
   `select: { id: true }`, and the two READS (getConsentBuzzBudget,
   listMyScopeGrants) catch P2022 SPECIFICALLY -- any other error still throws
   -- and treat it as "no budget set", which is the TRUE state of a database
   that cannot store one. The platform 50k/day cap enforces throughout. Logged
   once per process at error level. Migration header rewritten to the measured
   truth and to what is true after the fix.

2. reserveConsentBudgetSpend leaked its INCRBY when the following expire/ttl
   round-trip threw: the caller refunds the platform key only and never learns
   the consent key. On a first write the throwing call IS the expire that arms
   the TTL, so the leak had no expiry. All three cumulative reservers
   (platform, run-for-real, consent) now share one primitive that self-unwinds
   -- the platform and run-for-real legs had the same shape, so the class is
   fixed, not half of it.

3. The budget was write-once and invisible: no component read it, the modal
   only sends the field while the spend scope is still missing, and the
   documented null-clears branch had zero callers. Adds a render + edit + clear
   control on /apps/activity wired to the existing grantScopes raise path
   (scopes: [ai:write:budgeted] alone -- additive means anything wider would
   silently GRANT scopes). listMyScopeGrants gains spendScopeGranted, which the
   manifest scope list cannot answer. MIN=1 kept deliberately, with the reason
   written down: a low value is now recoverable in-product, and both surfaces
   warn below the lowest per-engine ceiling. Modal copy fixed -- it claimed "No
   limit set" while a stored limit was still enforced.

4. The post-paid settle leg was unguarded: four mutants survived 5,214 tests
   because nothing drove customComfy or step with a NON-NULL budget. Adds that
   coverage; three of the four now die. The fourth (consentBudgetKey on the
   step settle record) is UNREACHABLE -- no registered step is post-paid -- so
   it gets an asserted unreachability tripwire instead of a test that pretends.

5-6. Doc claims that contradicted the code: getMyBuzzBalance is not scope-free;
   the docs' buzz:read:self row and balance-read bullet said no scope was
   needed; the consent budget shipped undocumented. dev-token.ts justified its
   4h lifetime by a live moderator re-check that occurs ZERO times in either
   router -- replaced with the plain statement that the bound does not exist,
   and with what actually does bound it (self-bound, budget-capped, revocable).
   No replacement justification invented.

7-8. Migration keeps the plain CHECK, with the reason (the column is created
   NULL two statements earlier, so every row trivially satisfies it). The
   settle-correction comment claimed it corrects the legs the original
   reservation held; it re-derives them, so a submit straddling midnight UTC
   charges the next day's key. Comment fixed, behaviour left alone.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR97Ta6GaMaXecGKhDZwo7

* fix(app-blocks): three prose corrections + log the missing column from BOTH reads

Self-review of the round-1 fix commit's own claims:

- The reserve primitive's docblock quoted a refunded-to-0 figure the audit
  never reported. Restated as what was actually observed: the platform counter
  returned to its pre-attempt value while the consent counter stayed charged 25.
- The migration header said the CHECK's predicate is trivially satisfiable
  because the column was created "two statements earlier". It is the statement
  immediately above.
- The same header claimed BOTH reads log the missing column; only
  getConsentBuzzBudget did. listMyScopeGrants now logs it too, through the same
  once-per-process helper, so the sentence is true rather than reworded away.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR97Ta6GaMaXecGKhDZwo7

* docs(app-blocks): retire two scope rows the registry no longer has; state the budget editor's one known limit

Finishing the finding-5 doc pass rather than half of it.

- The Scopes table still listed `media:read:owned` and `block:settings:read`/
  `:write` as ordinary declarable scopes. Both were removed from
  BLOCK_SCOPE_TO_OAUTH_BIT in the "every declared scope is actually enforced"
  hygiene pass, so the manifest validator now REJECTS a manifest built to those
  rows. Marked retired, with what still holds (the OAuth MediaRead bit is
  untouched) and what does not.
- The two prose claims elsewhere in the file that assume those scopes are live
  — the 5-minute token lifetime and the installer-ownership check — are
  qualified rather than deleted: the code is still there, nothing can reach it.
  Leaving them unqualified would have made the file contradict its own table.
- `AppBudgetControl` now states its known limit instead of implying there is
  none: `grantScopes` requires the app to be approved and the scope to sit
  inside manifest ∩ approvedScopes, so if an app is un-approved the editor
  surfaces the server error. It is also enforcing nothing in that state, so the
  limit is frozen rather than stuck.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HR97Ta6GaMaXecGKhDZwo7

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:17:21 -05:00
Briant Diehl 05bb290f36 Merge pull request #4746 from civitai/feat/comfy-engine-default
Feat/comfy engine default
2026-09-10 11:06:36 -06:00
briant 0460b4c64b feat(generation): run ZImage and Qwen on comfy, via @civitai/orchestration-client beta.104
beta.104 publishes comfy variants of the ZImage and Qwen 20b imageGen inputs, so both
handlers, in the data-graph and form-graph lanes, now submit engine 'comfy' using the Comfy
input types from @civitai/orchestration-client instead of sdcpp with @civitai/client's.

Comfy is not a drop-in engine swap: its input names the sampling fields sampler/scheduler
where sdcpp used sampleMethod/schedule. Sending the sdcpp names under engine 'comfy' would
silently discard the user's sampler, and the handlers' casts hide that from typecheck, so the
tests assert the field names and not just the engine.

ZImage's scheduler picker now offers 'simple' only, since comfy has no 'discrete'. A stored
or remixed 'discrete' falls back to 'simple' in both lanes: selectNode and selectDef map values
outside their options to the default.

ZImage leaves SDCPP_SUPPORTED_ECOSYSTEMS, so it no longer gets the 2-for-1 quantity bonus,
consistent with the five comfy-only ecosystems that left it in the previous commit. A test pins
this; putting ZImage back on the list fails it.

Also corrects comments and docs that still described ZImage and Qwen as sdcpp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-10 11:02:51 -06:00
Manuel Emilio Urena ae0e86e4d7 fix(account): correct the account settings v2 pane on tester feedback (#4741)
* fix(account): correct the account settings v2 pane on tester feedback

Testers on the v2 redesign reported eight issues in a Discord thread; this
fixes all of them.

- Overview rendered an arbitrary owned badge and nameplate rather than the
  equipped ones. `userProfile.get` returns every owned cosmetic, unlike
  `userWithCosmeticsSelect`, and `Username` takes the first of each type.
- The Buzz total on Overview and in the mobile index counted only the
  domain's own account type, so blue was missing from the number itself,
  not merely from the breakdown.
- The CivBot Assistant toggle and its personality select were separated by
  the Chats toggle, because the feature list renders in flag declaration
  order. They now share a section of their own.
- The "Verify your email" alert claimed an unverified account cannot publish
  models, withdraw Buzz, or recover the account. Buzz withdrawal is not
  gated on email and there is no account recovery feature. It also keyed off
  `emailVerified` instead of `requiresEmailVerification`, so it showed to
  accounts that are gated on nothing. `VerifyEmailBanner` already covers the
  accounts that ARE gated, on the same page and with a working resend
  button, so this is removed rather than reworded.
- Profile & Account masks the email behind a reveal toggle; the Overview
  card no longer shows an address at all.
- Profile & Account gained a "Verify email" button wired to the existing
  `resendEmailVerification` procedure, which was previously reachable only
  from a banner that unstamped accounts never see.
- Content & Browsing leads with Ads and moves the eye-button note inside the
  Mature content section; "Blur mature content" now sits directly below
  "Show mature content".
- The Creator pane leads with Metric visibility instead of burying it under
  the sticker and remix sections.

Also drops a commented-out mutation in AdContent and the `disabled` props
that depended on it: the browsing-settings store persists itself.

`useAvailableBuzz`'s JSDoc claimed `baseTypes` defaults to `['blue']`; it
defaults to `[]`, which is what made the Buzz bug easy to write. Corrected,
along with the two docs that a reviewer found pointing at the old shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NoyDuuoL1BM1KvSShYBWT

* fix(account): tell email verification apart from an email change

The verification link is minted by two flows, and everything downstream
assumed the second one.

`sendEmailVerification` mints a token carrying the address the account
already has, so `newEmail` equals the current email. `/verify-email` read
that as a change and rendered "From: x → To: x" over a "Yes, Change Email"
button, and the email that sent the user there opened with "You requested to
change your email address".

Both flows are now told apart by comparing the token's address against the
current row, rather than by a new field in the payload — tokens already
issued keep working, and the page cannot disagree with what the write will
actually do. A verify-only token gets "Confirm Your Email Address", the one
address, and a "Verify Email" button; a change keeps the From/To it had.

Also on this path:

- The verification email carries the Civitai logo, using the header row the
  other templates already use, so it reads as official.
- `/verify-email` refreshes the client session on success and on the way back
  to account settings. The server already busts its own cached session; the
  tab that clicked the link was the stale half, and the Email tile kept
  reading "Unverified" until a hard reload.
- `emailVerificationEmail` is exported from the templates index. It was the
  only template missing, so `/api/testing/email/emailVerification` answered
  404 and the template could not be previewed at all.

Unrelated, on the Overview pane: the Buzz balance tile renders the shared
`UserBuzz` component rather than summing the accounts itself, so it shows one
blended total with the per-type split on hover. `UserBuzz` sets `lh={0}` on
its number, which lifts it off the baseline the other three tiles share, so
the tile overrides the line-height at that one call site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NoyDuuoL1BM1KvSShYBWT

* refactor(utils): generalize maskEmail into a reusable maskString

`maskEmail` was a one-caller module under `components/Account`, and nothing
about it was specific to email — it keeps a prefix, keeps a suffix, and puts a
fixed run of dots between them.

`maskString(value, { start, end, mask })` in `string-helpers` does that for any
string, and `maskEmail` is now a caller of it that works out where the domain
starts. The old module is gone; `ProfileCard` imports from `~/utils/string-helpers`.

The fixed-length run is the point and is now pinned by a test: a mask emitting
one character per hidden character passes every other assertion while still
telling a reader how long the secret is.

Two behaviours worth naming, both covered:

- Asking to keep more than the string holds returns the mask alone rather than
  the input untouched, so no caller can reveal a value by over-specifying.
- An email whose local part is a single character masks entirely.
  `a•••••@example.com` would show the whole address behind decoy dots, which is
  worse than an obviously hidden value.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016NoyDuuoL1BM1KvSShYBWT

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 12:26:57 -04:00
briant 6bec49c2d8 feat(generation): add ControlNet support for MiniMax H3
H3 comfy gains video ControlNet via the orchestrator's controlVideo
operation, backed by the H3 Fun ControlNet Union checkpoint. The picker
offers the five preprocessors PreprocessVideoInput implements — canny,
depth-anything-v2, dwpose, hed, mlsd — which is exactly the Union's
Canny/Depth/HED/MLSD/Pose set.

Auto mode emits a preprocessVideo step and $refs its blob into the gen
step; preprocessed mode passes a user-supplied control map straight
through. Wired in both the data-graph and form-graph lanes.

txt2vid only, and comfy only. ComfyMiniMaxH3ControlVideoInput inherits
neither firstFrame/lastFrame nor images, so the three H3 operations are
mutually exclusive — offering control on an image workflow would discard
the user's frames at submit time. Both the graph and the handler enforce
this; the handler re-checks because a stale value reaching it would fail
as lost input rather than as an error.

Also adds vid2vid:preprocess, the standalone control-preprocessor
workflow for video, mirroring img2img:preprocess. Its control map is the
deliverable, so unlike the in-generation step its output is not
suppressed. Both surfaces show before/after previews, labelled as stills
since no video samples exist.

Control types come from @civitai/orchestration-client: the pinned
@civitai/client predates preprocessVideo and has no equivalent type.

Notes:
- normalizeStepOutput and StepData.mediaType both defaulted an unknown
  step type to "no output" and "image" respectively, so a submitted
  preprocessVideo step rendered nothing. Both now handle it, with tests
  that fail on a revert.
- kindParams spread last in both preprocess handlers, letting a caller
  override the validated kind and the clamped resolution. Now spread
  first; the image handler had the same ordering.
- Extracts PreprocessKindParamsInput, shared by both preprocessor forms.
- Fixes stale comments claiming ControlNets are disabled everywhere and
  that PreprocessorExamples is used by the ControlNets input.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 15:49:40 -06:00
Manuel Emilio Urena 169e918a8c feat(account): finish the /user/account redesign behind accountSettingsV2 (#4727)
## What

Completes the `/user/account` redesign behind the `accountSettingsV2` flag. The earlier commits on this branch built the two-pane shell and converted three panes; this finishes the other five, then polishes the result against the Pencil canvas (`designs/user-account.pen`).

**OFF serves the legacy single-column page byte-identically.** See "Rollout" below.

## Panes

| Pane | Change |
|---|---|
| Overview | Tier badge art in the Membership tile (links `/user/membership`); Standing replaces the creator-score figure; username renders its nameplate + badge cosmetics; identity card stacks on mobile |
| Profile & Account | `ProfileCard` / `SocialProfileCard` flattened; Account standing shows the exact score; session refresh and delete are pointer rows, grouped |
| Preferences | Regrouped to Media playback / Generation / File preferences / Features; image format moved to File preferences; assistant folded into Features |
| Content & Browsing | Eye callout; mature-content rows; Topics as chips; hidden tags/users flattened |
| Creator | Placement, remix and metric-visibility sections; sticker inventory pointer moved inside Stickers |
| Membership & Billing | Subscription / payment methods / payouts flattened; gifts point at `/pricing/gift`; membership row stacks on mobile; empty states when the user has neither a membership nor a Creator Program payout config |
| Security & Apps | Sign-in methods, API keys, OAuth apps, connected apps flattened; create buttons on the section heading |
| Notifications | Delivery section; per-category icons; more room in an open category; `Other` sorted last |

## Decisions worth a reviewer's attention

- **Cards take a `flat` prop rather than being forked.** The legacy page mounts the same components while the flag is alive; two copies of a settings form is how one of them silently loses a field.
- **One rule per section.** Eight rows had nine dividers and read as a table. Rows are spaced instead.
- **`/user/account/overview` is a new URL.** On mobile the index renders the section *menu*, so an overview reachable only at the index has no way in. `AccountLayout` takes `isIndex` from the route now; inferring it from `section.path` rendered the menu at both URLs. Covered by a test — deleting the alias 404s that URL.
- **Standing thresholds moved to `accountStandingFromPoints`** (`strike.schema.ts`). Two surfaces show standing and it derives from active *points*, not the strike count.
- **The sticker-inventory pointer survives its host section's bail paths.** It is not gated on placement, so nesting it inside that section would drop it whenever the placement controls cannot render (flag off, or a failed spaces read).
- **`BrowsingCategories` switched to chips outright**, including the legacy card, rather than growing a variant prop — one rendering, no fork.
- **First use of a Tailwind `has-[…]` variant in this repo** (`SettingRow`, to keep switch rows inline at every width). Tailwind is 3.4.17, so it is supported.
- **Billing empty states are `flat`-only.** `SubscriptionCard` and `UserPaymentConfigurationCard` both returned `null` with nothing to show, which left the whole pane blank. They now offer the plans / the Creator Program instead — but only in the flat panes, so the legacy page keeps hiding them and stays byte-identical. Both reuse the metric-visibility upsell, extracted as `UpsellPanel`.

## Verification

Typecheck clean; no new lint warnings. Covering suites green: `account-sections` (17), `strike.service` + `process-strikes` (75), the four Account browser suites (24), and the notification suites (79).

`SettingsCard.earlyAdopter.browser` needed one assertion updated — it pinned the literal early-adopter copy. Kept its intent (the opt-in must explain itself) and split it into the promise and the caveat rather than loosening it.

Walked every pane at 1440px and 390px as a subscribed Creator Program account, and the billing/notification changes on a free account with no Creator Program.

## Not in this PR

- `designs/user-account.pen` has uncommitted local changes that predate this work; left alone deliberately.

## Rollout

`accountSettingsV2` → Flipt key `account-settings-v2`.

`availability: ['mod']` is the STATIC FALLBACK only — it decides nothing while Flipt answers, so it matters solely during a Flipt outage, where mods get the new shell and everyone else keeps the legacy page. The Flipt rollout is the on-switch: `account-settings-v2` is `enabled: false` with no rollouts today, so the page is off for everyone until a segment or threshold rollout is merged in `flipt-state`. Instant rollback = drop that rollout / set the threshold to 0.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013NnY26APwddt5dySmmubkZ
2026-09-09 16:08:19 -04:00
briant d54868ebf5 feat(generation): add a 1K/2K base resolution tier to Krea 2
Krea 2 generated at a fixed ~1MP across every aspect ratio. The comfy
builds now take a resolution tier where 2K doubles each bucket to ~4MP,
which is the low end of what was asked for (CU-868m15v7g) and what the
orchestrator is now ready to serve.

Defaults to 1K, so per-generation Buzz cost only moves for someone who
opts in. The FAL medium/large tiers get no selector: that API takes size
+ aspectRatio with no width/height, so a tier there would promise
dimensions the orchestrator never reads. Edit and community checkpoints
keep it, since both always run a comfy build.

Mirrored across both graph lanes for the dual-graph window, with three
new differential shapes covering comfy-2K, FAL-rejects-tier and
edit-keeps-tier. The Resolution control also moves above Aspect Ratio in
both forms, and the docs row claiming it lives in the Advanced accordion
under Wan/Sora was wrong in both halves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 11:02:15 -06:00