Skip to content

feat(app): add civitai app metrics <slug> for owner-only App analytics - #190

Merged
ZacxDev merged 3 commits into
mainfrom
feat/app-metrics
Aug 3, 2026
Merged

feat(app): add civitai app metrics <slug> for owner-only App analytics#190
ZacxDev merged 3 commits into
mainfrom
feat/app-metrics

Conversation

@ZacxDev

@ZacxDev ZacxDev commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

What

Adds civitai app metrics <slug> — the authenticated owner's analytics for one of
their own App Blocks: installs, runs + Buzz spent, Buzz purchased, and API
engagement. Registered under newAppCmd() alongside status / withdraw.

This is CLI plumbing only — no server change. The blocks.getMyAppAnalytics
tRPC proc already exists and is live on civitai.com; nothing here adds or alters
a server-side route. The slug → appBlockId resolution reuses the existing
GET /api/v1/blocks/submissions client (appapi.Lister), the same route
civitai app status reads — no new resolution endpoint was introduced.

$ civitai app metrics gen-matrix --from 2026-05-01 --to 2026-08-03
App:          gen-matrix
Window:       2026-05-01 00:00 UTC → 2026-08-03 00:00 UTC
Granularity:  week

Installs
  Total   12
  Active  9

Runs
  Count       20
  Buzz spent  65

Buzz purchased
  Purchases  3
  Buzz       15000
  Gross      $14.97

Engagement
  API calls     26
  Active users  2
  Error rate    0

  Top scopes:
    ai:write:budgeted  20

  Top endpoints:
    /api/v1/blocks/me  4

The three correctness requirements, and how each is tested

Each of these is a case where the obvious implementation prints something that
looks like data but isn't. They are the reason the command is shaped this way.

1. The printed window comes from the response, never the flags

The server defaults to the last 30 days, clamps any request to 366 days,
and returns zeros for data that exists outside that window — an app with 20
runs in mid-June reads 0 under the default window. So a zero is meaningless
without the period it covers, and echoing the requested window would be a lie
whenever the server clamped it. The renderer prints range.from / range.to /
range.granularity straight from the payload.

Tested by TestAppMetricsClampedRangeEchoesResponseNotFlags: the CLI requests
--from 2020-01-01, the fake server answers with a range clamped to
2025-08-03, and the test asserts the output contains the clamped start and
does not contain
2020-01-01. TestAppMetricsRendersVerifiedPayload and
TestAppMetricsGenuineZeroRendersWithWindow additionally pin that the window is
present on both a populated and an all-zero read.

2. "Not entitled" is distinguished from "genuinely zero"

The proc sits behind the appBlocksAuthor feature flag (moderators-only by
default) and, when the caller doesn't match it or doesn't own the app, answers
HTTP 200 with every counter zeroed — flagged only by the payload's
notOwned boolean. Rendering that would present a permission failure as a real,
empty dashboard, which is the primary failure mode here. On notOwned the
command renders nothing and returns an actionable error naming
civitai whoami and civitai app status <slug>.

Tested by TestAppMetricsNotOwnedRefusesDashboard, which asserts both halves:
the error text, and that stdout contains none of Installs / Runs /
Buzz purchased / Engagement / Granularity. Its complement,
TestAppMetricsGenuineZeroRendersWithWindow, feeds all-zero counts with
notOwned:false and asserts the dashboard does render (with its window) and is
not reported as a permission problem — so the two cases can't collapse into
each other.

3. A 403 names the fix instead of saying "forbidden"

The proc declares no requiredScope, so it defaults to full scope: an OAuth
login token gets a 403 where a personal API key succeeds. The 403 branch says to
create a key and run civitai login --token <key>, and points at
civitai whoami to see which credential is active. Per AGENTS.md item 4, no
scope bitmask is vendored — this is purely the HTTP status plus guidance.

Tested by TestAppMetricsForbiddenAsksForPersonalAPIKey, which asserts the
error contains 403, personal API key, civitai login --token and OAuth
not merely that some error occurred.

Also fixed while building it

A literal null tRPC payload ({"result":{"data":{"json":null}}}) unmarshals
cleanly into the zero struct and would have rendered as an all-zero dashboard
over an empty window — the exact silent-empty failure requirement 2 is about. It
is now rejected as malformed. This was caught by a test written before the code
handled it, and watched fail first.

Flags

  • --from / --to — a bare YYYY-MM-DD (interpreted as midnight UTC, so
    the window is reproducible regardless of where the CLI runs) or a full RFC3339
    timestamp, normalised to UTC on the wire. A malformed value or an inverted
    window is an ErrUsageexit 2, decided client-side before any request
    (asserted: the tests check no HTTP call was made). Omitting both bounds omits
    them from the wire so the server's own 30-day default applies.
  • --json — passes the server's unwrapped payload through verbatim (not
    re-marshalled through the CLI's structs), so scripts keep every field
    including notOwned and the per-bucket series arrays the human view omits.

Test coverage

internal/cmd/app_metrics_test.go drives the command end-to-end through
NewRootCmd() + SetArgs against an httptest server serving both routes
(submissions + tRPC) — no live network. 31 test cases including subtests:

Path Test
happy path, verified-shape fixture TestAppMetricsRendersVerifiedPayload
--json passthrough (unwrapped, no rendered text) TestAppMetricsJSONPassthrough
notOwned:true → no dashboard TestAppMetricsNotOwnedRefusesDashboard
genuine zero + window printed TestAppMetricsGenuineZeroRendersWithWindow
server clamps the range TestAppMetricsClampedRangeEchoesResponseNotFlags
403 → personal-API-key guidance TestAppMetricsForbiddenAsksForPersonalAPIKey
401 → civitai login TestAppMetricsUnauthorizedPointsAtLogin
no token configured TestAppMetricsMissingTokenErrors
slug with no submissions TestAppMetricsUnknownSlugIsActionableNotFound
all appBlockId: null → "no approved block yet" TestAppMetricsNoApprovedBlockYet
newest row null, older row approved TestAppMetricsPicksNewestNonNullAppBlockID
malformed envelope ×5 (non-JSON, no data, null, wrong type, empty) TestAppMetricsMalformedEnvelopeIsCleanError
--from/--to parsing ×3 (date, RFC3339, offset→UTC) TestAppMetricsWindowFlagsAccepted
bad window ×3 → exit 2, no request made TestAppMetricsBadWindowIsUsageError
both bounds omitted when unset TestAppMetricsNoWindowFlagsOmitsBothBounds
resolution failure surfaces TestAppMetricsSubmissionsErrorSurfaces
formatting units TestUSDFromCents, TestUTCStamp

Every error-path test asserts the specific message or behaviour, not merely
that an error occurred.

Mutation sweep — 13/13 mutants killed. Each guard was broken on purpose and
confirmed to go red on its own assertion (not a different guard's): the window
echo, the notOwned branch (two mutants — one that skips it, one that renders
before erroring, so both halves of the assertion are load-bearing), the 403
message, the null-payload rejection, the resolver's fall-through to the
approved row, both asUsageError tags, the inverted-window check, date-only
parsing, --json passthrough, and both formatting helpers. The sweep was run
with a green baseline before and after, and the tree verified restored.

Verification

$ make ci
go mod tidy
go vet ./...
go test ./...
ok  	github.com/civitai/cli	0.016s
ok  	github.com/civitai/cli/cmd/civitai	0.023s
ok  	github.com/civitai/cli/internal/antipattern	0.006s
ok  	github.com/civitai/cli/internal/appapi	0.194s
ok  	github.com/civitai/cli/internal/auth	0.006s
ok  	github.com/civitai/cli/internal/cmd	7.112s
ok  	github.com/civitai/cli/internal/config	0.005s
ok  	github.com/civitai/cli/internal/devtunnel	0.027s
ok  	github.com/civitai/cli/internal/dnsprobe	0.457s
ok  	github.com/civitai/cli/internal/manifest	0.005s
ok  	github.com/civitai/cli/internal/pkgzip	0.045s
ok  	github.com/civitai/cli/internal/scaffold	0.025s
ok  	github.com/civitai/cli/internal/scaffold/cmd/bump-pins	0.005s
ok  	github.com/civitai/cli/internal/ui	0.003s
ok  	github.com/civitai/cli/internal/validate	0.021s
ok  	github.com/civitai/cli/pkg/civitai	0.128s
go build ...

Counted rather than read off the exit code: 1311 === RUN, 1309 --- PASS,
0 --- FAIL, 2 --- SKIP, 0 panics, 16 packages ok
. Both skips
(TestScanDirFromEnv, TestScaffoldPinsSatisfyPublished) are pre-existing and
environment-gated, unrelated to this change. gofmt -s -l . prints nothing.

The built binary was also driven against a local fake server for the three
user-visible paths: the render above (exit 0), notOwned (error, no dashboard,
exit 1), and --from last-tuesday (usage error, exit 2).

Docs

README gets the command-table row and an App metrics section (the sample
output above is real CLI output, not hand-written), covering the three
requirements plus the data caveat: engagement counts only authenticated,
scope-gated API calls
, so an app with no scoped API surface shows real
installs and revenue with a flat engagement section — expected, not a bug. The
same caveat is in the command's Long help, and the human output prints a note
when apiCalls == 0.

Note for review

errorRate is rendered verbatim rather than as a percentage. Its unit isn't
determinable from the verified payload (the observed value is 0), and
multiplying by 100 on a guess would be a silent 100× error in a user-facing
figure. If the server's contract says it's a 0–1 ratio, converting it is a
one-line follow-up.

🤖 Generated with Claude Code

ZacxDev and others added 2 commits August 2, 2026 22:50
Adds the CLI plumbing for the existing server-side blocks.getMyAppAnalytics
tRPC proc: resolve a slug to its appBlockId through the caller's own
submissions (the route `civitai app status` already uses — no new resolution
endpoint), then render installs / runs + Buzz spent / Buzz purchased /
engagement. No server change is involved.

Three properties the command exists to get right, because each one otherwise
produces a believable-but-wrong reading:

* The window is printed from the RESPONSE, never the flags. The server
  defaults to 30 days and clamps to 366, and it returns zeros for data that
  exists outside that window — an app with 20 runs in mid-June reads 0 under
  the default. Echoing range.from/to/granularity keeps a zero unambiguous.
* `notOwned` is honoured before anything renders. The proc is behind the
  `appBlocksAuthor` flag and answers 200-with-all-zeros rather than an error
  when the caller is not entitled, so a naive renderer presents a permission
  failure as a real, empty dashboard. That case now errors with the account
  checks to run instead.
* A 403 names the fix. The proc declares no requiredScope, so it defaults to
  full scope and an OAuth login token is refused where a personal API key
  works; the error says `civitai login --token <key>` rather than "forbidden".

--from/--to accept a bare YYYY-MM-DD (midnight UTC) or full RFC3339; a
malformed value or an inverted window is a usage error (exit 2) decided before
any request. --json passes the server's unwrapped payload straight through, so
scripts keep the series arrays and notOwned.

Also rejects a literal `null` tRPC payload, which would otherwise unmarshal
cleanly into a zeroed struct and render as an empty dashboard over an empty
window.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi
… contract, document the --json notOwned gap

Three audit findings on `civitai app metrics <slug>`.

1. `engagement.errorRate` is a 0-1 RATIO, not a pre-scaled percentage. The
   server computes `errorRate = apiCalls > 0 ? errorCount / apiCalls : 0`
   (civitai/civitai src/server/services/blocks/app-analytics.service.ts), so
   printing it verbatim showed `Error rate 0.02040816326530612` where the
   reader wants `2.0%`. The human view now renders it via `pctFromRatio` at one
   decimal place (an exact zero reads `0.0%`). `--json` is untouched and still
   passes the server's raw ratio through.

2. The exit-code classification was untested. Stripping the `TagStatus`
   deferral in `analyticsError` and the `ErrNotFound` tag on the unknown-slug
   guard left every error MESSAGE byte-identical, so the whole `TestAppMetrics*`
   suite stayed green while 401/403 -> exit 3 and 404 -> exit 4 (promised by
   README + the taxonomy in root.go) silently degraded to exit 1. The 401, 403,
   unknown-slug and server-404 tests now assert `errors.Is` against
   `civitai.ErrUnauthorized` / `civitai.ErrNotFound` alongside the existing
   message assertions, which are unchanged.

3. `--json` deliberately fails OPEN on a not-entitled read: unlike the human
   view it passes a `notOwned: true` payload through with every counter zeroed
   and still exits 0, so `... --json | jq .runs.count` yields a 0 that is
   indistinguishable from a genuine zero. Behaviour is kept as-is (raw
   passthrough is the point of the flag); the gap is now documented in the
   --json flag help and in README, both saying scripts must branch on the
   `notOwned` field.

Also fixes a rendering bug found while writing (3): pflag's UnquoteUsage lifts
the first back-quoted span out of a usage string and uses it as the flag's value
name, so a usage string mentioning a back-quoted `notOwned: true` rendered the
boolean as `--json notOwned: true`. Back-quotes dropped, with a regression test.

Verified by mutation: each new assertion was watched go red with its own
message (errorRate reverted to verbatim -> 3 tests red; TagStatus neutered ->
401/403/404 red; ErrNotFound tag dropped -> unknown-slug red; notOwned sentence
removed -> help test red; back-quotes reintroduced -> flag-render test red), with
a no-mutation control green. Full suite: 1315 RUN / 1313 PASS / 0 FAIL / 2 SKIP
(the two pre-existing environment-gated skips), gofmt + vet clean. The README
sample output was regenerated from the built binary against a local fake server,
not hand-written.

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

ZacxDev commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Audit fixes pushed — 07731cf

Three findings addressed. Nothing was taken on faith: every new assertion was watched go red under a deliberate mutation before being trusted.

1. errorRate rendered as a raw ratio 🟡

engagement.errorRate is a 0–1 ratio, settled from the server source rather than guessed — app-analytics.service.ts:276 computes:

const errorRate = apiCalls > 0 ? errorCount / apiCalls : 0;

The human view printed it verbatim, so a real app read Error rate 0.02040816326530612. It now goes through a pctFromRatio helper at one decimal place. Observed from the built binary against a local fake server serving that exact live payload:

  Error rate    2.0%

and an exact zero reads 0.0% (not 0.0000%, not NaN). --json is untouched — the same run confirms it still emits "errorRate": 0.02040816326530612 verbatim.

2. The exit-code contract was untested 🟡 (the important one)

The audit stripped the classification while leaving every error message byte-identical, and the whole TestAppMetrics* suite stayed green — so README + the taxonomy at root.go:205-216 promised 401/403 → exit 3 and 404 → exit 4 with nothing pinning it.

Fixed by adding errors.Is assertions alongside the existing message assertions (none were weakened or restructured), mirroring how the usage-error test already pins ErrUsage:

test new assertion
TestAppMetricsUnauthorizedPointsAtLogin errors.Is(err, civitai.ErrUnauthorized) — exit 3
TestAppMetricsForbiddenAsksForPersonalAPIKey errors.Is(err, civitai.ErrUnauthorized) — exit 3
TestAppMetricsUnknownSlugIsActionableNotFound errors.Is(err, civitai.ErrNotFound) — exit 4
TestAppMetricsServer404ClassifiesNotFound (new) errors.Is(err, civitai.ErrNotFound) — exit 4

The last one is new because the two not-found paths are tagged differently: the unknown-slug guard uses an explicit civitai.Tag, while an analytics-query 404 is tagged by analyticsError's TagStatus deferral. One test could not cover both.

3. --json fails open on notOwned 🟡

Behaviour deliberately unchanged — raw passthrough at exit 0 is the point of the flag, and the test at app_metrics_test.go pinning it is left alone. Documented instead, in the --json flag help and in README.md: a notOwned: true payload is passed through with every counter zeroed and still exits 0, so civitai app metrics <slug> --json | jq .runs.count returns 0 for an app you can't see, and a script must branch on notOwned rather than trusting the counts.

Bonus: a rendering bug found while writing that doc string

pflag's UnquoteUsage lifts the first back-quoted span out of a usage string and uses it as the flag's value name. Mentioning a back-quoted `notOwned: true` payload therefore rendered the boolean as:

--json notOwned: true   emit the raw analytics payload …

i.e. the help told you to pass an argument to a flag that takes none. Back-quotes dropped; TestAppMetricsJSONFlagRendersAsABoolean now pins it.


Verification

Mutation matrix — each mutation applied to the source, suite re-run, failure message recorded, source restored. M0 is the no-mutation control (proves the harness can observe a pass); M2 and M3 are exactly the two mutations that previously stayed green.

# mutation result failing assertion
M0 (control — none) green
M1 pctFromRatioFormatFloat(…, 'g', -1, 64) red ×3 metrics output missing "0.0%" · error rate 0.25 should render as 25.0% · errorRate 0.020408… should render as 2.0% · the human view must not print the raw ratio
M2 defer func() { err = civitai.TagStatus(status, err) }() → no-op red ×3 a 401 must classify as ErrUnauthorized (exit 3), got *errors.errorString · same for 403 · a 404 must classify as ErrNotFound (exit 4)
M3 drop civitai.Tag(civitai.ErrNotFound, …) on the unknown-slug guard red ×1 an unknown slug must classify as ErrNotFound (exit 4), got *errors.errorString
M4 remove the notOwned sentence from the --json help red ×1 metrics help missing "notowned"
M5 pctFromRatio drops the *100 red ×1 pctFromRatio(0.25) = "0.2%", want "25.0%"
M6 reintroduce back-quotes in the --json usage string red ×1 --json picked up a value name from a back-quoted usage span

Under M2 and M3 only the new classification assertions fire — every message assertion still passes, which is the whole point: those mutations are invisible to message-shape tests.

Full suite (go test ./... -count=1 -v, counted rather than read off the exit code):

=== RUN   : 1315
--- PASS  : 1313
--- FAIL  : 0
--- SKIP  : 2      (TestScanDirFromEnv, TestScaffoldPinsSatisfyPublished — pre-existing, environment-gated)
panic: test timed out : 0

gofmt -s -l . prints nothing, go vet ./... clean, go build ./... ok.

README sample — the Error rate line in the civitai app metrics sample block was regenerated by running the built binary against a local fake server (payload matching the sample's other figures: 1 error in 26 calls), not hand-edited. It now reads Error rate 3.8%.

No live credentials were used; both binary checks ran against a throwaway loopback server. No force-push, not merged.

Rendering errorRate as a one-decimal percentage collapsed every ratio below
0.0005 to `0.0%` — byte-identical to a genuine zero. An app with 5,000 API
calls and 2 errors (errorRate 0.0004) read `Error rate  0.0%`, i.e. as having
no errors at all. That band is exactly where a healthy, high-traffic app sits,
so the loss landed on the population the command is most useful for; the raw
passthrough this replaced did distinguish the two.

pctFromRatio now renders `<0.1%` for anything nonzero that would otherwise
round to zero. The decision is made on the RENDERED string rather than a
hard-coded 0.0005 threshold, so the two cannot drift apart at the float64
boundary. Only an exact zero prints `0.0%`; everything at or above the
threshold keeps the existing one-decimal form; `--json` is untouched and still
passes the server's raw ratio through verbatim.

Also documents the actual behaviour on the function (the old comment claimed
only that an exact zero reads `0.0%`, which was true of inexact near-zeros
too) and pins the [0,1] input precondition to the server invariant it rests
on: `errorCount` counts a strict subset of the rows `apiCalls` counts (the
same block_scope_invocations filter plus statusCode >= 400), so negative, >1,
NaN and Inf are not producible.

Tests: TestPctFromRatio gains the sub-0.0005 band and both sides of the
boundary — the gap that let this ship — plus TestPctFromRatioTinyRateIsNotZero,
which asserts only that a nonzero rate never renders identically to zero, and
an end-to-end TestAppMetricsTinyErrorRateRendersDistinctly covering the
dashboard and --json together.

Verified: 4-mutant matrix (revert-to-bare-format, drop the r>0 guard, shift the
band to 0.1, disable the --json branch) — every new assertion watched fail with
its own message and the source restored byte-identically. `make ci` green,
1327 run / 1325 pass / 0 fail / 2 expected env-gated skips. Built binary run
against a local fake server: 0 -> 0.0%, 0.0004 -> <0.1%, 1e-5 -> <0.1%,
0.0005 -> 0.1%, 0.02040816326530612 -> 2.0%, 0.25 -> 25.0%, with --json raw in
every case.

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

ZacxDev commented Aug 3, 2026

Copy link
Copy Markdown
Contributor Author

Closed the last audit finding: a real-but-tiny error rate no longer reads 0.0%

Pushed 9315070.

The finding. The previous round's pctFromRatio (one-decimal percentage) collapsed every ratio below 0.0005 to 0.0% — byte-identical to a genuine zero. An app with 5,000 API calls and 2 errors (errorRate: 0.0004) printed Error rate 0.0%, i.e. as having no errors at all. That band is exactly where a healthy, high-traffic app sits, so the information loss landed on the population this command is most useful for. The raw passthrough it replaced did distinguish the two.

The fix. Anything nonzero that would otherwise round to zero now renders <0.1%:

pct := strconv.FormatFloat(r*100, 'f', 1, 64)
if r > 0 && pct == "0.0" {
    return "<0.1%"
}
return pct + "%"

The branch is decided on the rendered string, not a hard-coded 0.0005 constant, so the threshold and the formatter cannot drift apart at the float64 boundary. Constraints held: an exact 0 still prints 0.0%; everything at or above the threshold keeps the existing one-decimal form; --json is untouched and still passes the server's raw ratio through verbatim (the pinning test is unchanged and still green).

Also addressed the 🟢 on the same function: the doc comment claimed only that "an exact zero reads 0.0%" — true of inexact near-zeros too, which is what let this slip. It now states the real behaviour and pins the [0,1] precondition to the server invariant it rests on: errorCount counts a strict subset of the rows apiCalls counts (same block_scope_invocations filter plus statusCode >= 400, service lines 241-256), and the divide is guarded by apiCalls > 0 — so negative / >1 / NaN / Inf are not producible by this server, and the function deliberately does not defend against them.

Test gap that let it ship

TestPctFromRatio covered {0, 0.0204, 0.25, 0.005, 1} and never entered the sub-0.0005 band. It now covers the band (0.0004, 1e-5, math.SmallestNonzeroFloat64) and both sides of the boundary (math.Nextafter(0.0005, 0)<0.1%, 0.00050.1%). Two new tests:

  • TestPctFromRatioTinyRateIsNotZero — the discriminating one. It asserts nothing about what the small rendering is, only that a nonzero rate can never produce the same string as zero. Reverting the fix fails it.
  • TestAppMetricsTinyErrorRateRendersDistinctly — the same regression end-to-end through the command: the dashboard shows <0.1% while --json still emits 0.0004.

Verification

Mutation matrix — every new assertion was watched fail, with its own error message, and the source restored byte-identically afterwards (sha256 match). Control run before the sweep was green (14/14), so the harness can report both colours.

Mutant Result Which new assertions went red
control (unmutated) 14 pass / 0 fail
M1 revert to bare FormatFloat(r*100,'f',1,64) (the exact regression) 7 fail all 4 band cases, TinyRateIsNotZero (all 4 ratios), the e2e dashboard assertions
M2 drop the r > 0 guard 3 fail exact zero stays 0.0% + the TinyRateIsNotZero zero-precondition Fatalf
M3 shift the band to pct == "0.1" 8 fail all band cases and exactly the 0.0005 boundary (0.0005 wrongly became <0.1%) — pins both sides
M4 disable the --json passthrough branch 2 fail both --json assertions in the new e2e test (positive control proving that half is wired to real output)

M2 and M3 exist because M1 alone leaves the exact-zero case and the upper boundary untested — they'd pass with the guard broken in the other direction.

Gate. make ci green, gofmt -s -l . empty. Counted, not read from the exit code: RUN 1327 / PASS 1325 / FAIL 0 / SKIP 2, no panic: test timed out. Baseline before the change was 1315/1313/0/2, so +12 = 10 new TestPctFromRatio subtests + 2 new tests. The 2 skips are the pre-existing env-gated TestScanDirFromEnv and TestScaffoldPinsSatisfyPublished.

Live rendering — the built binary (not the test harness) driven against a local fake server, since there are no live credentials here:

server errorRate human Error rate --json
0 0.0% 0
0.0004 <0.1% 0.0004
1e-5 <0.1% 1e-05
0.0005 0.1% 0.0005
0.02040816326530612 2.0% (unchanged) 0.02040816326530612
0.25 25.0% (unchanged) 0.25

The harness carried a negative control (an unknown slug must exit non-zero — it exits 4), so these rows are known to be real round-trips rather than a silently-stubbed path.

README. The sample block's Error rate 3.8% is unaffected and still matches what the binary prints for that ratio; the surrounding paragraph gained one sentence noting that only a genuine zero reads 0.0%. Its other numbers were left alone — it's an illustrative composite, not a capture.

🤖 Generated with Claude Code

https://claude.ai/code/session_01858ymA3tEJQi83435u7npi

ZacxDev added a commit that referenced this pull request Aug 3, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi
@ZacxDev
ZacxDev merged commit c715352 into main Aug 3, 2026
10 checks passed
ZacxDev added a commit that referenced this pull request Aug 3, 2026
#191)

Adds items 5-7 to "Intentional decisions that look wrong (read before
'fixing')" and updates the section's framing sentence.

5. `civitai app metrics` calls tRPC, not REST — because there is no REST route
   to call. Owner analytics exist only as `blocks.getMyAppAnalytics`; there is
   no /api/v1 equivalent. The command therefore resolves slug -> appBlockId via
   the existing REST GET /api/v1/blocks/submissions and then issues the
   non-batched tRPC GET, reusing the authedDo + result.data.json unwrap pattern
   GetForgejoCloneInfo established. Documented so nobody "fixes" it into a REST
   call that does not exist.

6. `notOwned` is a cross-repo contract. The proc sits behind the
   `appBlocksAuthor` flag and answers a non-entitled caller with HTTP 200 and
   every counter zeroed, so a renderer that ignores it prints a plausible empty
   dashboard for a permission failure. The human view refuses to render on
   `notOwned`; `--json` deliberately passes it through and still exits 0, so
   scripts must branch on it themselves. Whoever changes the payload
   server-side has to keep the field.

7. The exit-code contract is pinned by errors.Is, never by message text. The
   sentinels carry no visible text (Tag/TagStatus preserve Error() byte-for-
   byte), so a message assertion says nothing about the exit code. Measured on
   the metrics PR: stripping the classification while leaving every message
   identical left the ENTIRE suite green, and the README's 403 -> exit 3 /
   not-found -> exit 4 promise was unpinned. Generalised to every command that
   claims an exit code.

Sentinel names verified against the code, not paraphrased: the HTTP kinds are
`civitai.ErrUnauthorized`/`ErrNotFound` in pkg/civitai/errkind.go, but the
usage sentinel is `cmd.ErrUsage` in internal/cmd/usage_error.go (there is no
`civitai.ErrUsage`) — the item says so explicitly.

DEPENDS ON #190: internal/cmd/app_metrics.go and internal/appapi/analytics.go
exist only on `feat/app-metrics` and are NOT on main. Merge this AFTER #190 or
AGENTS.md will describe a command the tree does not have.

`make ci` green: tidy + vet clean, 16/16 packages ok, 0 FAIL; `gofmt -s -l`
prints nothing.


Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev added a commit to civitai/civitai that referenced this pull request Aug 3, 2026
…token can read its own analytics (#3572)

* fix(app-blocks): scope-annotate getMyAppAnalytics so the CLI login token can read its own analytics

`blocks.getMyAppAnalytics` carried no `.meta({ requiredScope })`, so
`enforceTokenScope` fell back to its default of `TokenScope.Full`. A Full
personal API key therefore worked, but the OAuth token minted by `civitai login`
— the CLI's DEFAULT auth path, and the only one most users have — was rejected
with FORBIDDEN "Your API key does not have the required scope for this action".
That made `civitai app metrics <slug>` (civitai/cli#190) unusable for the
majority of its intended audience.

Annotated with `TokenScope.AppBlocksSubmit`, chosen deliberately:

- It is the bit `GET /api/v1/blocks/submissions` already requires, and
  `civitai app metrics <slug>` calls BOTH — submissions to resolve
  slug -> appBlockId, then this proc. Any other bit would leave the two hops of
  a single command needing different scopes.
- It keeps that route's stated rule: the same credential that could submit can
  read its own app data, and nothing weaker.
- NOT `UserRead`: UserRead is inside `Full`, i.e. what every third-party
  "log in with Civitai" client requests. This proc exposes install counts, Buzz
  spend, endpoint names and active-user counts, so gating it on UserRead would
  expose a developer's app economics to any such client. AppBlocksSubmit is
  opt-in and deliberately excluded from `Full`.
- NOT `AppBlocksDevTunnel` (the `stopDevTunnel` precedent): that bit means "open
  an on-site dev tunnel" and its consent label says so. Reusing it would force
  analytics readers to grant tunnel-opening and vice versa.

Same fix, same bit, and same test shape as the listing-media procs in
`app-listings.router.cli-scope.test.ts` (civitai/cli#186) — an identical
un-annotated-proc 403.

No regression and no migration: `enforceTokenScope` early-returns on
`ctx.tokenScope === TokenScope.Full`, and `createContext` defaults a session to
Full, so Full personal keys and the web `/apps/revenue` panel are unaffected.
The `civitai-cli` OAuth client is already provisioned with this bit and the
login token already carries it, so no `allowedScopes` change is needed.

Verification: of the five tests added, only "CLI OAuth login token … reaches the
service" is regression coverage — with the `.meta` line deleted it is the sole
test that goes red, and it fails with this gate's own error at
enforce-token-scope.ts:84. The other three behavioural tests pass on pre-change
code and are labelled in the file as invariant guards, not regression guards.
Full unit suite 755 files / 10989 passed / 1 skipped / 0 failed; tsc --noEmit 0
errors (harness validated against an injected type error reporting exactly 1).

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

* fix(app-blocks): annotate getMyForgejoCloneInfo too, and pin what the no-regression claim rests on

Audit round on #3572. Three substantive changes plus two comment corrections.

1. `getMyForgejoCloneInfo` had the SAME defect and is live. It is `protectedProcedure`
   with no `.meta`, so it implicitly required `TokenScope.Full` and 403'd the OAuth
   login token exactly as getMyAppAnalytics did — and `civitai app pull` drives it
   (cli/internal/appapi/appblocks.go:739). The CLI renders that failure as "not
   permitted (are you the app owner, and is Apps enabled for your account?)", which
   sends the developer hunting an ownership problem they do not have. Annotated with
   the same bit, plus the same 3-case matrix in its own test file.

   `getMyRevenue` and `getMyApps` stay un-annotated deliberately — no CLI command calls
   them, so they are reached only by the session-authed web panel, which takes the Full
   early-return regardless. Said so in a comment, because two adjacent identically-
   gated procs differing with no explanation is a trap for the next reader.

2. The no-regression claim was stated as unconditional and is not. enforceTokenScope's
   bypass is exact equality (`ctx.tokenScope !== TokenScope.Full`), NOT hasFlag, so a
   strict SUPERSET of Full that lacks bit 25 — `Full|AppBlocksDevTunnel` = 100663295 —
   satisfied the un-annotated gate before and is FORBIDDEN after. Verified numerically:

     mask        before              after
     33554431    PASS(early-return)  PASS(early-return)
     100663297   FORBIDDEN           PASS         <- the fix
     100663295   PASS                FORBIDDEN   <- the flip

   No such credential can exist, but that is enforced by schema caps rather than luck:
   api-key.schema.ts caps a personal key at .max(Full) and oauth-client.schema.ts caps
   allowedScopes at .max(Full) on both create and update. Those caps are now pinned by
   a test, with a boundary assertion that they still ADMIT Full so they cannot be
   vacuously rejecting. If one is raised, the flip becomes reachable.

3. The comment claimed "No allowedScopes migration either: the civitai-cli client is
   already provisioned with this bit" — asserting prod state this repo cannot see. Both
   relevant migrations are manual-apply and the device flow lives on the auth hub.
   Reworded to say what is actually knowable, and to note the failure mode if bit 25 is
   absent in prod is "this fix does not take effect", never "a working path breaks".

Also: clarified the regression-coverage note (three BEHAVIOURAL invariant guards plus
the enum-only sanity test = four green, one regression guard — the previous wording
read as contradicting the file's own closing line), and corrected a stale pre-existing
docstring that said `moderatorProcedure` where the proc is `appDeveloperProcedure`.

Verification — every mutation verified to have actually applied before its result was
read, and all three source files confirmed byte-identical to their backups afterwards:
  - drop `.meta` from getMyForgejoCloneInfo -> 1 red, failing with the gate's own
    TRPCError "Your API key does not have the required scope for this action";
  - raise the api-key cap                   -> the superset test red;
  - raise the oauth-client create cap       -> the superset test red (so it is
    load-bearing on both caps independently, not just one).
Full unit suite 755 files / 10994 passed / 1 skipped / 0 failed; tsc --noEmit 0 errors,
no OOM. Prettier: the one violation my earlier region introduced is fixed; the
remaining violations in both test files are pre-existing on main (classified by hunk
position, and the invocation positive-controlled against a known violator).

NOT addressed, deliberately: the `AppBlocksSubmit` consent label ("Submit Apps for
review") now also authorizes an analytics read and under-describes that. It is shared
consent copy in @civitai/auth, so it is a product-copy decision rather than a drive-by
edit — flagged for the maintainer instead.

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

* docs(app-blocks): correct what the scope-gate comments and the cap test actually prove

Delta re-audit round on #3572. No behaviour change — the code is untouched except for
one added boundary assertion. What changed is what the change SAYS about itself, in the
two places the previous round got it wrong in exactly the way it was meant to fix.

1. The superset-of-Full justification named the wrong enforcement mechanism. It said the
   flip is "enforced by schema caps rather than luck", citing `.max(Full)` in
   api-key.schema.ts and oauth-client.schema.ts. Those caps are real but they do NOT
   govern OAuth access tokens: the hub decodes a requested scope against `ALL_SCOPES`,
   not `Full`, deliberately (clamping to Full would drop a legitimately-requested opt-in
   bit), so a token's ceiling is its client's `allowedScopes`. And the one client
   exceeding Full — civitai-cli, 100663297 — got that value from a RAW SQL migration,
   which no zod schema governs. The flip really is unreachable, because 100663297 is not
   a superset of Full (bit 0 and none of 1..24) — but that last step rests on INSPECTION
   of a migration, not on a cap. Comment now says so, and names what would silently
   escape: a future migration granting some client `Full | <opt-in bit>`.

   The test was titled "no creatable credential can hold a superset-of-Full mask", which
   claimed the global property it cannot prove. Renamed to "the two zod-validated
   credential surfaces reject a superset-of-Full mask", with its docstring stating the
   two surfaces it covers and the two it cannot.

2. The green-test count went stale again, broken by this PR's own previous round: the
   comment said "FOUR green tests" while the block now holds six tests, five green on
   pre-change code — the round appended the fifth green test 78 lines below the comment
   asserting the count. Rather than write a fifth correct number that the next round can
   invalidate, the comment now tells the reader to count at the source and records why:
   a hardcoded count in a docstring is a claim with no gate on it.

Also from the re-audit:
- Recorded the bit-choice reasoning for `getMyForgejoCloneInfo` separately instead of
  inheriting "same reason as getMyAppAnalytics". It is not the same stake: analytics is
  a pure read, whereas this proc MINTS A CREDENTIAL (a per-user Forgejo identity whose
  token carries `write:repository`, returned embedded in `cloneUrl`), so annotating
  widens who can trigger that mint to include the CLI login token. Accepted, with the
  bounds that make it acceptable written down — and the note that if an `AppBlocksRead`
  bit ever lands, this proc is the one that must NOT move to it.
- Added the missing boundary control for `updateOauthClientSchema`. Its two siblings each
  had a `Full -> success` assertion isolating the cap as the cause of the rejection; the
  update path had only the rejection. Verified load-bearing: raising ONLY the update cap
  turns the test red, so all three caps are now independently pinned.
- Fixed the last stale `moderatorProcedure` comment (the proc is `appDeveloperProcedure`;
  the "moderator" test NAMES are fine, since fakePerUserFlag keys the author capability
  off isModerator — noted inline so the next reader does not "fix" the names).

Verification: both touched test files 27/27; full unit suite 755 files / 10994 passed /
1 skipped / 0 failed; tsc --noEmit 0 errors, no OOM. Prettier: zero hunks in the added
regions (classified by hunk position); the remaining violations in both files are
pre-existing on main.

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

* docs(app-blocks): fix the author-capability mechanism claim (third audit round)

Third delta re-audit on #3572 found a 🔴 in a comment written by the SECOND round to
fix a comment — the same defect class, third occurrence. Comment/docstring only; no
behaviour change.

🔴 The parenthetical added last round said `appDeveloperProcedure` passes because
"fakePerUserFlag keys the author capability off isModerator". Wrong, and verified
wrong: `hasAppBlocksAuthor` (trpc.ts) reads ONLY
`getFeatureFlags(ctx).appBlocksAuthor`, this test file does not mock
feature-flags.service at all (0 references), and `fakePerUserFlag` is the mock
implementation of `isAppBlocksEnabled` — the DARK flag, which is the second clause of
that same sentence. So the comment attributed both gates to one mock and got the first
one wrong, while contradicting the file's own header docstring.

The discriminator needs no mutation: this very test forces
`mockIsAppBlocksEnabled.mockResolvedValue(false)` and still returns successfully. If
that mock keyed the author capability, the proc would throw FORBIDDEN first.

Now states both gates and how each is actually satisfied (static `availability: ['mod']`
fallback vs the mocked dark flag), keeping the note that the "moderator" test names are
correct — both gates key off isModerator, via different code.

Also from the same round:
- "Same gate" -> "Same SCOPE gate", plus a note that the base procedures DIFFER
  (`appDeveloperProcedure` vs `protectedProcedure` + inline feature check), which
  matters because the block goes on to argue about who can trigger the credential mint —
  a broader audience than the author cohort.
- The AppBlocksRead note was internally tense: moving getMyAppAnalytics to a future read
  bit alone would recreate the "two scopes for one command" problem, since
  `civitai app metrics` also calls `GET /api/v1/blocks/submissions`. Now says they must
  move together.
- The non-zod-writer list read as an exhaustive partition and omitted a third writer:
  publish-request.service writes `allowedScopes` for `appblk-*` clients. Safe by
  construction (mapped bits all below 25, `grants: []` so no bearer token) rather than by
  any cap the test asserts on. Listed, and labelled as "known when written", not a
  partition.
- Corrected "a token's ceiling is its client's allowedScopes" to `allowedScopes | UserRead`
  (all three hub sites OR in bit 0); pointed "the test below" at the right file; replaced
  an `enforce-token-scope.ts:84` line reference with the function name so it cannot rot.

Verification: both touched files 27/27; full unit suite 755 files / 10994 passed /
1 skipped / 0 failed; tsc --noEmit 0 errors, no OOM; zero prettier hunks in the added
regions.

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

* docs(app-blocks): correct the getMyAppRepo comparison my scope annotation made stale

Fourth audit round on #3572 came back clean on the previous delta, but noted that the
annotation makes an UNCHANGED neighbouring docstring imprecise: it claimed
getMyForgejoCloneInfo is 'distinct from getMyAppRepo only in intent'. That was true
before this PR and is not now — the two also differ in token scope, since only this one
carries `.meta({ requiredScope: AppBlocksSubmit })`.

Verified while fixing it that getMyAppRepo is NOT a fourth instance of the same bug:
zero references in the CLI (positive control: getMyForgejoCloneInfo has 5 in
internal/appapi/appblocks.go), and its callers are web-only (AuthorViaGit.tsx,
git-access.ts, browser tests). So it is session-authed, takes enforceTokenScope's Full
early-return, and is correctly left un-annotated — now stated rather than left for the
next reader to re-derive.

Comment now enumerates all three real differences (token scope, intent, collaborator
read-vs-write) and confirms ownership gating IS still identical.

Comment-only. Full unit suite 755 files / 10994 passed / 1 skipped / 0 failed;
tsc --noEmit 0 errors, no OOM; getMyAppRepo/getMyForgejoCloneInfo/getMyAppAnalytics test
files 33/33; prettier clean on the touched file (positive-controlled).

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
ZacxDev added a commit that referenced this pull request Aug 3, 2026
Captures the state of #190, civitai/civitai#3557 and #3561, the
stale-integration-gate blocker, the measured per-app analytics, ranked
follow-ups, and the session's reusable lessons.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi
ZacxDev added a commit that referenced this pull request Aug 3, 2026
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01858ymA3tEJQi83435u7npi
ZacxDev added a commit that referenced this pull request Aug 4, 2026
* docs: handoff for app-analytics CLI command + two platform fixes

Captures the state of #190, civitai/civitai#3557 and #3561, the
stale-integration-gate blocker, the measured per-app analytics, ranked
follow-ups, and the session's reusable lessons.

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

* docs: re-gate cleared the stale-gate blocker; #3557 prettier fixed

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

* docs: record the three docs PRs and the #191-after-#190 merge constraint

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

* docs(handoff): record the shipped outcomes so the doc stops reading as a to-do list

The app-analytics handoff has been sitting on this branch, unmerged, describing three open
PRs and a re-gate in flight. All of that shipped — 8 PRs across both repos, plus follow-ups
#1, #2 and #4 — so as written the doc's most prominent content is a set of live-sounding
directives for work that is done. A stranded doc that is also stale is worse than no doc.

Changes:

- A RESOLVED banner up top with the full merged-PR table, replacing "nothing merged".

- Neutralised the "Do not merge until that re-gate reports" imperative, and recorded that
  the rebase it anticipated WAS needed, for a different reason: #3566 landed later and
  edited the same `detail: {}` object, turning #3561 CONFLICTING after its gate had passed.

- Struck follow-ups #1, #2 and #4 with what actually happened, including the two places
  this doc was WRONG:
    * #1's entry missed a second proc with the identical live defect —
      `getMyForgejoCloneInfo`, which `civitai app pull` drives. It was found by an audit,
      not by the list, which is worth knowing about ranked follow-up lists in general: the
      list is not a survey.
    * #2's suggested fix (reuse `humaniseScopeEndpoint`) would have shipped a bug. Measured
      against the real function it returns '(no workflow id)' for `workflow:submit` and ''
      for `user-settings:write`, because it is the per-ROW labeller and an aggregate bucket
      has no `detail`.

- Recorded #1's scope decision with the prod evidence that later confirmed it: 331 live
  tokens unblocked, 30 of which lack bit 26 — so copying the nearest precedent
  (AppBlocksDevTunnel) would have left those 30 still 403ing. Plus the measurement trap:
  `(mask & Full) = Full` is also true when `mask == Full` and reports 145 false hits; the
  strict-superset form needs `AND mask <> Full`.

- New "Still open — start here" section: the CI `component`-tier gap (three PRs shipped
  browser tests that have never run on a canonical browser), the stale-node_modules trap
  that silently removes ~1,126 tests, the unverified `addCollaborator` downgrade lead, and
  a note that `installs: 0` should be assumed broken until a positive control exists.

- "What actually caught the bugs": across 12 adversarial audit rounds every fix round found
  a defect in the previous fix, and the mechanical gate caught none of them — the suite and
  typecheck were green at every tip.

Doc-only.

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

* docs(handoff): retract the "CI does not run the component project" claim — it was false

The "Still open" section asserted that CI does not run the `component` (browser) project at
all, and used that to frame the browser tests shipped today as having no CI coverage.
**That was wrong.** The `preview / component-tests` external check runs exactly that
project.

Recording HOW the error happened, because the mechanism is more useful than the fact: every
status query written during the session filtered for
`Unit tests|Typecheck|ESLint|event-engine`, so `preview / component-tests` was never in a
result set — and its absence from those results was read as evidence it did not exist. The
evidence was selected and then the selection was treated as the finding. The claim was then
repeated in three PR comments and this doc without ever being checked directly.

What the check actually shows on #3574, the PR that shipped 7 browser tests:
  075519d380 (before the audit-fix round)  success
  8e75826616 (first fix round)             FAILURE
  928273e4dd (second fix round)            FAILURE

It is report-only, which is why the merge was not blocked. Cutting the other way: PR 3591
PASSES component-tests on a base that contains the #3574 merge, while PR 3594 fails on the
same base — green for some PRs, red for others, which looks like flakiness or
content-dependence rather than a defect #3574 introduced. Genuinely unresolved.

Also recorded: this cannot be settled from a NixOS host. The full-project component run does
not complete locally either WITH the merge (crashes, "Browser connection was closed") or
WITHOUT it (times out at 25 minutes) — measured both ways specifically to check whether the
local failure was attributable to the change. It is not. Single-file runs pass, cold cache
included, so a local run can validate one file and says nothing about the suite. The preview
pipeline's logs are the only authority.

And one genuinely stale thing found while investigating, left as an actionable note:
`lint.yml`'s unit job excludes browser tests on the grounds that they "carry the
cold-optimizeDeps flake documented at vitest.config.mts:98-124" — but that section documents
the FIX, and a cold-cache single-file run passes. The stated reason no longer holds.

Doc-only.

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

* docs(handoff): add the followups handoff, with the component-tests root cause

Second doc on this PR. The predecessor records the original session; this one records where
the stream stands now and carries one fully-diagnosed open bug.

The headline: `preview / component-tests` went success -> failure exactly at #3574's
audit-fix round, and the root cause is mine. The 'Runs (range)' stat's tooltip begins
'Generations run through your app...', my test asserts getByText('Generations'), and
getByText is substring + case-insensitive — so the locator resolves to 2 elements whenever
that hover-only tooltip is mounted. civitai#3593 established that vitest browser mode shares
ONE page across every .browser.test.tsx file, so a leftover pointer position from an earlier
file can have it open at mount: fails in a full-suite run, never in a single-file run, which
is exactly the observed pattern. Confirmed by computation that the PREVIOUS label
('Generation submits') did NOT substring-match that tooltip, so the vocabulary rename is what
introduced it.

Doc includes the verbatim fix to apply, the eliminations (cold-optimizeDeps flake is fixed;
PR 3591 passes on a base containing the merge, so the suite is not universally broken; a local
repro cannot settle it because the full-project run fails on this host with AND without the
change), and the environment traps that produced false greens all session.

Doc-only.

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

* docs(handoff): mark the component-tests investigation resolved (civitai#3606)

Records the fix, the proof matrix, and the reproduction trap: the tooltip target is the 14px
IconInfoCircle, not the label — hovering the label does NOT mount it, so a probe without its
own positive control reads as 'hypothesis wrong' and would have shipped an unproven fix.

Also flags what is still unconfirmed: preview/component-tests had not reported on #3606 at
merge time, so the next PR's result on that check is the confirmation to read.

Doc-only.

* docs(handoff): sync the header with the fix, and record the pins-vs-published blocker

Goal/state lines still described the component test as outstanding. Also records why cli#193
is BLOCKED: pins-vs-published is red on main too (npm published past the scaffold pins), it has
nothing to do with these docs, and the one-command fix unblocks it while fixing a real defect.

Doc-only.

* docs(handoff): record the pins blocker as fixed by cli#194

The `pins-vs-published` blocker described here is resolved — cli#194 (189d5a4)
bumped @civitai/app-sdk ^0.28.0 -> ^0.30.0 and @civitai/blocks-react ^0.37.0 ->
^0.38.0 via the repo's own bump-pins command.

Three corrections to what this doc told the next session:

- The bumper rewrites scaffold_test.go's assertions ITSELF, along with the
  package.json.tmpl and README.md.tmpl literals. The doc's "then update the
  matching assertions in scaffold_test.go" implied a hand-edit that would have
  been redundant.
- The blocker is RECURRING, not a one-off: it fires whenever npm publishes a new
  @civitai/* minor, on any open PR regardless of content. Noted, with a pointer
  to check why bump-scaffold-pins.yml did not open the bump PR on its own.
- Recorded the one thing the bumper gets slightly wrong (a README prose line
  that overstates the minimum SDK version post-bump), so it is not rediscovered.

Also renumbers the ranked list and strikes the two completed items.

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

* docs(handoff): correct the bump-pins lead — cron latency, not a broken workflow

My previous commit told the next session to "check why bump-scaffold-pins.yml
did not open the bump PR itself". Measured it instead of leaving the hint: the
workflow is fine and the framing was wrong.

The scheduled run fired Mon 2026-08-03 10:51 UTC and correctly no-op'd. Both
publishes landed LATER THE SAME DAY — @civitai/blocks-react 0.38.0 at 20:15 UTC,
@civitai/app-sdk 0.30.0 at 22:13 UTC (npm registry `time` field). A successful
no-op run and a broken detector look identical from the run list alone; the
publish timestamps are the signal that separates them.

The real exposure is the weekly cron (`17 7 * * 1`): a required check can sit
red on main and every open PR for up to ~7 days after any @civitai/* minor.
Records the two consequences — the fixed remedy (or `gh workflow run`), and
that the lever is cron frequency, flagged as ask-first per AGENTS.md.

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

---------

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant