GLOOK-37: expose per-developer model and skills usage via MCP - #65
Conversation
GLOOK-37. The MCP server surfaced only two Claude Code fields per developer (cc_total_cost, cc_requests). The per-model and per-product breakdowns that GLOOK-30 ingests were reachable from the REST routes and three UI surfaces but not over MCP, because the MCP server is a separate query layer that was never extended to read them. Adds two tools rather than widening query_developer_stats, so callers that don't want breakdowns don't pay for them — and so the visibility asymmetry between the two is visible at the tool boundary rather than buried in one response shape: query_model_usage cc_model_usage rows gated query_skills_usage cc_skills_usage rows ungated query_model_usage gates `requests` as well as `cost`. requests is not currency, but summing a developer's per-model requests reconstructs the gated cc_requests exactly — same window, merely grouped by model — so gating only the dollar figure would reopen what stripDevCost closes. Gating reuses stripModelCost, which drops a hidden developer's rows wholesale rather than blanking amounts, so "cannot see this developer" stays indistinguishable from "has no Claude usage". querySkillsUsage deliberately takes no requester parameter. Skills usage is ungated by policy (see CC_FIELDS in cost-visibility.ts: skill counts convert to no currency, so they sum to no gated figure), and omitting the parameter makes that a compile-time fact instead of a convention a later edit can quietly break. Also extracts the fail-closed visibility resolution out of queryDeveloperStats into resolveCostVisibility, so the rule has one implementation rather than one per tool. The admin / no-requester short-circuits stay ahead of the reportOrg lookup: mcp-cost-gating.test.ts asserts exact db.execute call counts to prove the fail-closed path performs no team query. Both queries INNER JOIN developer_stats and compare logins case-insensitively, for the reasons documented above the equivalent queries in report/org.ts — otherwise they report a wider population than every other per-report figure, and silently drop case-mismatched logins. Verified beyond the unit tests, which mock db.execute and so cannot see the SQL: ran both queries against a real SQLite database through the translating wrapper, confirming the INNER JOIN excludes a login absent from developer_stats, that LOWER() matches 'carol' against 'Carol', and that DECIMAL cost returns 12.5 rather than "12.50". Also ran both against the seeded glooker.db. The requester-forwarding test was mutation-checked: registering the handler as (a) => queryModelUsage(a) makes it fail, which is the point — that bug would fail closed and silently return nothing for everyone. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
msogin
left a comment
There was a problem hiding this comment.
Review summary
Four independent reviewers: a senior-backend-architect pass (correctness), a security pass (authorization & inference), the standard Smartling fullstack review, and a minimalist scope gate. Findings deduplicated and verified against the code before posting. No fixes were applied — this is comment-only.
| Severity | Count |
|---|---|
| 🔴 critical | 1 |
| 🟡 warning | 3 |
| 🟣 question | 2 |
| 🔵 suggestion | 8 |
The one that matters
LIMIT is applied before gating (queries.ts:294, :346). Three reviewers reached this line independently, each seeing a different consequence: entitled rows becoming unreachable, count acting as an inference oracle on hidden developers, and silently partial aggregates. One fix closes all three.
This also refutes the PR description's justification for leaving it as-is. The description says the behavior "mirrors queryDeveloperStats exactly." It doesn't — stripDevCost keeps rows and blanks fields, so the limit is harmless there; stripModelCost returns [], which is what converts a pre-gating limit into a defect. The asymmetry between the two strip functions is the whole story, and it's the reason the consistency argument doesn't transfer.
On the description's other claims
The PR makes a careful case for its design; most of it holds, two parts don't:
requestsis gated, not justcost— holds, and I verified all four returned fields drop wholesale, not just the two discussed. Worth noting this required zero lines in this PR:requestswas already inMODEL_COST_FIELDSat base, soqueryModelUsageinherits it by callingstripModelCost. The gating is real; the credit belongs to #64.- Fail-closed with one implementation — holds, and it's the strongest of the four claims. Every input shape was exercised (absent requester, empty/null login, unknown login, no team, missing
reports.org, DB error mid-lookup, nonexistentreport_id): all yield zero rows or error before any data read.resolveCostVisibilityis semantically identical to the inline logic it replaces, and the admin/no-requester short-circuits do stay ahead of the org lookup. - "Hidden is indistinguishable from no Claude usage" — holds within one response, not across the tool surface.
query_developer_statsdistinguishes the two in a single call by key presence (stripCostFieldsdeletes the key;cc_total_costisNOT NULL DEFAULT 0, so genuine zero-usage returns0). See the comment onmcp-cost-gating.test.ts:88— the fix is most likely three comments, not a behavior change. - Two tools rather than widening
query_developer_stats— the right call, but not for the stated reason. "Callers that don't want breakdowns don't pay for them" doesn't hold up: MCP callers pay per invocation either way. The split survives on the second reason given — the two datasets have opposite gating, so one tool would have to accept a requester and ignore it for half its output. querySkillsUsagetaking norequestermakes ungated-ness "a compile-time fact" — weak as stated, sinceMcpTool.handleris typed(args, requester?)and nothing would have errored either way; a later edit can add the parameter in one line. It costs nothing, so keep the signature — but it is the sole justification for one removable test.
Cross-cutting
- Units belong in the MCP contract. This surface feeds an LLM that formats numbers for humans. Any money field should be named
*_cents/*_usdrather than relying on a convention documented only inspend-tab.tsx. Fixquery_developer_stats'scc_total_costin the same pass so the two tools agree. - Gate in SQL, not after the fetch. On the fail-closed path the entire result set is fetched and discarded; on the team-scoped path the row budget is spent on rows the caller will never see. Resolving visibility first fixes the critical finding and the wasted work together.
- The tests over-cover what mocks can see and under-cover what matters.
db.executeis mocked, so theINNER JOIN developer_statsandLOWER()predicates — which exist to prevent a bug that already shipped once — are pinned by nothing, while several new tests re-assert branches already covered elsewhere in the same file. Reallocating effort from the latter to the former is a net gain in both directions. - Prose duplication. Four comment blocks in
queries.tsrestate rationale owned bycost-visibility.tsandreport/org.ts. Since the drop policy already changed once, every copy is a place that can silently keep describing the old behavior.
Assessment
Ready to merge? With fixes.
The visibility design is sound and correctly reuses stripModelCost's drop-wholesale semantics — the fail-closed work in particular is careful and verified. The blocker is that a pre-gating LIMIT interacts badly with row-dropping in three separate ways, all fixable locally in the two new functions. The cents-as-dollars exposure is a small change with a large blast radius on reported numbers.
One note on reachability: the critical finding needs a report where more than limit (developer × model) rows sort ahead of the caller's own. The seeded DB is 16 rows across 8 developers, so it won't reproduce locally — worth checking against a real report to calibrate urgency.
🤖 Four-reviewer pass via /sl-core-dev:review-loop (1 loop, 2 personas + minimalist gate) and claude-pr-review-local. Findings verified against the code, deduplicated, no fixes applied.
| ON d.report_id = cc_model_usage.report_id AND LOWER(d.github_login) = LOWER(cc_model_usage.github_login) | ||
| WHERE ${conditions.join(' AND ')} | ||
| ORDER BY cc_model_usage.github_login, cc_model_usage.cost DESC, cc_model_usage.model | ||
| LIMIT ?`, |
There was a problem hiding this comment.
🔴 critical
LIMIT is applied before gating, and stripModelCost deletes rows — three independent consequences.
Three reviewers reached this line from different angles; it's one root cause.
The PR description says this "mirrors queryDeveloperStats exactly; kept consistent rather than diverging." It doesn't, and that's the crux:
stripDevCost(cost-visibility.ts:138) —devs.map(...), keeps every row, blanks fields. Row count survives gating, so LIMIT-before-gating costs nothing.stripModelCost(cost-visibility.ts:178) —if (canSeeCost(devLogin)) return models; return [], drops rows.
So here the row budget is spent in SQL on rows that are then deleted in JS. What follows:
1. Entitled rows are unreachable. Rows come back ORDER BY github_login ascending, capped at 100 by default. A non-admin whose login sorts after 100 rows' worth of other developers gets {models: [], count: 0} — including their own spend. cost-visibility.ts:116 (if (dev === requesterLogin) return true) makes "own cost is always visible" an explicit guarantee; this breaks it. The calling agent will report "no per-model usage is recorded."
2. count becomes an inference oracle. Varying limit and reading only count recovers the ordinal positions of hidden developers' rows. With ungated order alice×2, bob×1, carol×3, dave×1:
limit : 1 2 3 4 5 6 7
count : 1 2 2 2 2 2 3
Every non-incrementing step is an ordinal holding a hidden row → hidden rows at [3,4,5,6]. Since query_developer_stats already gives the full login list in sort order ungated, a gap between two visible logins is that developer's distinct-model count — precisely the signal this line's own comment at :305 says rows are dropped to conceal ("retaining 'which models' alone still discloses that a non-teammate uses Claude Code and roughly how expensively"). No dollar amount leaks, which is why this is an inference channel rather than direct disclosure.
3. Aggregates are silently partial. Truncation is alphabetical and can cut mid-developer, so summing a visible developer's per-model cost disagrees with their cc_total_cost, with nothing in the payload signalling it.
Fix — resolve visibility first and bound the SQL limit to MAX_ROWS, then gate and slice:
const vis = await resolveCostVisibility(r.id, requester);
// ...SQL with LIMIT bound to String(MAX_ROWS), not args.limit
const truncated = rows.length >= MAX_ROWS;
const visible = models.filter(m => vis.canSeeCost(m.github_login));
const limit = Number(clampLimit(args.limit, 100));
return { models: visible.slice(0, limit), count: Math.min(visible.length, limit), truncated };Pushing the visible-login set into the WHERE clause is stronger still and makes count honest by construction. Note report/org.ts:302 reads this same table with no LIMIT at all for the UI panel, so an unbounded read is already the established pattern here.
Reachability: needs a report where more than limit (developer × model) rows sort ahead of the caller's own. The seeded DB is only 16 rows / 8 developers, so this won't reproduce locally — worth calibrating against a real report before deciding severity.
mcp-cost-gating.test.ts:99's expect(mockExecute).toHaveBeenCalledTimes(2) currently pins the wasted query on the fail-closed path; an early return changes it to 1.
| ON d.report_id = cc_skills_usage.report_id AND LOWER(d.github_login) = LOWER(cc_skills_usage.github_login) | ||
| WHERE ${conditions.join(' AND ')} | ||
| ORDER BY cc_skills_usage.github_login, cc_skills_usage.skills_used DESC, cc_skills_usage.product | ||
| LIMIT ?`, |
There was a problem hiding this comment.
🟡 warning
Same pre-emptive LIMIT on the skills query, with no truncated signal.
No gating is involved here, so none of the visibility issues apply — but ORDER BY github_login … LIMIT 100 still returns an alphabetical prefix and the response has no way to say so. An admin asking "which skills does the org use most" gets a total computed over logins a–j only, reported as the whole org.
For the entity-listing siblings, truncation is meaningful (queryCommits is ORDER BY committed_at DESC → "the most recent N"). "All of a–j and none of k–z" is not a defensible sample of anything. getMetricTimeseries already returns a truncated flag for exactly this hazard, and queryUnmergedWork binds MAX_ROWS rather than 100.
Fix: bind MAX_ROWS in SQL, return truncated: rows.length >= MAX_ROWS, and apply the caller's limit in JS.
| const models = rows.map((row: any) => ({ | ||
| github_login: String(row.github_login), | ||
| model: String(row.model), | ||
| cost: Number(row.cost) || 0, |
There was a problem hiding this comment.
🟡 warning
cc_model_usage.cost holds cents; this emits it as an unqualified cost, so agents will report figures 100× too high.
Verified end to end:
src/lib/db/mysql.ts:191—cost DECIMAL(10,2)src/lib/cc-spend/apply-breakdowns.ts:167insertsagg.costCentsinto that column- Every existing consumer divides by 100:
spend-tab.tsx:37,usage-card.tsx:30,74,76,profile-content.tsx:145,180,182,dev-table.tsx:265,team-table.tsx:158
The UI divides at render. An MCP client has nothing to tell it to, and tools.ts:115 says only "cost". Asked "how much did Alice spend on opus?", a model reads cost: 1250 and answers $1250.00 for $12.50.
The fixtures in this PR (cost: '12.50') read as dollars, which suggests the ambiguity is already biting.
Fix — make the unit explicit in the wire shape:
cost_cents: Number(row.cost) || 0,
// or: cost_usd: (Number(row.cost) || 0) / 100,query_developer_stats has the same latent problem with cc_total_cost. Whichever way you go, change both together — two Claude-cost tools disagreeing on unit would be worse than both being in cents.
| } | ||
| const gated = [...byLogin.entries()].flatMap(([login, devRows]) => stripModelCost(devRows, vis.canSeeCost, login)); | ||
|
|
||
| return { models: gated, count: gated.length }; |
There was a problem hiding this comment.
🟡 warning
resolveCostVisibility computes canSeeAnyCost and this discards it, so "you may see no costs" is indistinguishable from "this report has no model usage."
The wholesale row-drop deliberately makes a hidden developer indistinguishable from a developer with no usage — that part is right. But this also conflates a property of the requester with a property of the data, and the former discloses nothing.
resolveRequester returns githubLogin: null for an authenticated user with no user_mappings row, and CLAUDE.md documents that as a real population ("Jira Cloud instances with hidden email visibility will cause auto-discovery to fail silently"). That user gets {models: [], count: 0} and the agent states as fact that no usage is recorded. Same for a mapped user on no team.
The org REST route already handles this — it strips cc_period_start/cc_period_end/spendWindow when !canSeeAnyCost so the UI can say "you cannot see costs." The MCP tool says nothing.
Fix:
return { models: gated, count: gated.length, cost_visible: vis.canSeeAnyCost };Then an agent seeing cost_visible: false with an empty list can say "you don't have visibility into Claude spend" instead of "there is none." Worth a line in the tool description too.
|
|
||
| expect(out.models).toEqual([{ github_login: 'alice', model: 'opus', cost: 12.5, requests: 7 }]); | ||
| // Not merely blanked — carol contributes no row, so "hidden" is | ||
| // indistinguishable from "has no Claude usage". |
There was a problem hiding this comment.
🟣 question
This comment — and the same claim in stripModelCost's doc — overclaims. Within one response it holds; across the MCP surface it doesn't.
The row-drop works, and with a login filter a hidden developer is byte-identical to a nonexistent one. But sibling tools in the same registry answer the question directly:
query_developer_statskey presence.stripCostFields(cost-visibility.ts:129) doesdelete copy[f], whiledeveloper_stats.cc_total_costisNOT NULL DEFAULT 0(sqlite.ts:34,mysql.ts:276). So a developer with genuinely no usage returnscc_total_cost: 0— key present; a hidden developer returns no key at all. One call, no probing:alice present=true value=1000 (visible teammate) erin present=true value=0 (no usage) carol present=false value=undef (hidden)- The
count/limitstaircase inquery_model_usageitself — see the comment onqueries.ts:294. query_skills_usage— a hidden developer withproduct: 'claude_code'andskills_used > 0near-certainly hascc_model_usagerows. Probabilistic (different endpoint,SKILLS_LAG_DAYS-clamped window), but strong. Worth noting this PR makes per-developer Claude activity reachable via MCP for the first time — no existing MCP query exposes anycc_activity column.
Route 1 is pre-existing in stripDevCost and out of scope to change here. What's new is the claim that the property holds, which future work may build on.
Two ways to resolve, and the code currently matches documented policy (CLAUDE.md and the CC_FIELDS comment both say skills are deliberately never gated), so this is a question, not a defect:
(a) Accept the residual and soften the wording — the property is "amounts and model mix are withheld," not "hiddenness is unobservable." That means this comment, lines 56–57, and the third paragraph of stripModelCost's doc comment. Cheap and consistent with existing policy.
(b) Make the property hold — needs stripCostFields to blank rather than delete, plus gating skills rows for cost-hidden developers. That contradicts CLAUDE.md and is well beyond this PR.
(a) looks right. Either way the count oracle should be fixed independently — it leaks per-developer model counts, which is a step past mere hiddenness.
| requests: Number(row.requests) || 0, | ||
| })); | ||
|
|
||
| // Group by login so stripModelCost sees one developer's rows at a time: it |
There was a problem hiding this comment.
🔵 suggestion
This comment and three others restate rationale that lives in cost-visibility.ts and report/org.ts — now two copies to keep in sync.
:258— "see the long comment above the equivalent queries in report/org.ts. In short: …" then recaps it for six lines. The pointer was the whole value.:270— restatesMODEL_COST_FIELDS' doc comment in substance verbatim.:305(this one) — restatesstripModelCost's doc comment, closing with "See its doc comment.":323— "see the policy comment on CC_FIELDS in cost-visibility.ts" then repeats that comment.- A third pass over the same reasoning at
mcp-cost-gating.test.ts:55-57and:128-129.
The implicit justification is that a reader of queries.ts shouldn't have to jump — i.e. for clarity. That's the trade that produces drift, and cost-visibility.ts is where the policy lives and already says all of this. Keep the one-line pointers, drop the recaps.
Sharper reason to care: the drop policy already changed once (the #64 review moved it from field-stripping to row-dropping). Every prose copy is a place that silently keeps describing the old behavior.
| * them. That ordering is load-bearing: mcp-cost-gating.test.ts asserts exact | ||
| * db.execute call counts to prove the fail-closed path performs no org lookup. | ||
| * | ||
| * Shared by every MCP query that returns cost-bearing data, so the fail-closed |
There was a problem hiding this comment.
🔵 suggestion
"Shared by every MCP query that returns cost-bearing data" overclaims.
It has two callers (:253 and :309), and querySkillsUsage deliberately doesn't use it — so "every" is already false in this same file by design. As written it reads as an invariant a future edit should maintain, when the actual rule is narrower.
Lines :46-47 also pin the helper's doc to one test file's assertion style, which will age badly if that test is ever restructured.
The extraction itself is well justified — two real callers land in this PR and it's behavior-preserving against the inlined version. Only the doc needs scoping down.
| const list = byLogin.get(m.github_login); | ||
| if (list) list.push(m); else byLogin.set(m.github_login, [m]); | ||
| } | ||
| const gated = [...byLogin.entries()].flatMap(([login, devRows]) => stripModelCost(devRows, vis.canSeeCost, login)); |
There was a problem hiding this comment.
🔵 suggestion
This group-by-login + stripModelCost dance is duplicated byte-for-byte at src/app/api/report/[id]/org/route.ts:19-27.
stripModelCost is a security control, so two copies means a future policy change has to be found twice — and this policy has already changed once (#64 moved it from field-stripping to row-dropping). Worth extracting alongside stripDevCost:
export function gateModelRowsByLogin<T extends ModelBearing & { github_login: string }>(
rows: T[], canSeeCost: (login: string) => boolean,
) { /* group by login, flatMap stripModelCost */ }To be explicit about one thing I checked and am not suggesting: replacing this with models.filter(m => vis.canSeeCost(m.github_login)) would be behaviorally identical today and six lines shorter, but routing through stripModelCost is what keeps a single implementation of the drop policy — a local filter is exactly what would have silently diverged when the policy changed. Keep the indirection; extract the wrapper.
| }, | ||
| { | ||
| name: 'query_model_usage', | ||
| description: "Per-developer Claude Code cost and requests broken down by model, for a report. Cost and requests are visible only for developers whose spend the caller may see; other developers' rows are omitted entirely.", |
There was a problem hiding this comment.
🔵 suggestion
Two fixes to this description string.
1. State the unit. cc_model_usage.cost holds cents (see the note on queries.ts:301). Whatever you decide about the wire shape, this string is the cheapest place to say so.
2. "Claude Code cost" understates the feed. CLAUDE.md is explicit that user_cost_report is "a multi-surface feed (claude.ai + Claude Code + API), not Claude Code-only." As written, a model will attribute the whole figure to coding — and this is the only description it ever sees.
Suggested: "Per-developer Claude cost in USD cents (all surfaces: claude.ai + Claude Code + API) and requests broken down by model, for a report. …"
| }, | ||
| { | ||
| name: 'query_skills_usage', | ||
| description: 'Per-developer Claude Code skills usage broken down by product (skills invoked, distinct skills), for a report. Activity volume only, no cost — visible for all developers in the report.', |
There was a problem hiding this comment.
🔵 suggestion
Two fixes here as well.
1. product isn't Claude Code only. Values include chat and cowork — this PR's own fixture uses 'chat'. Calling it "Claude Code skills usage" while returning claude.ai rows will have a model misattribute them.
2. The window differs from the report period. Per CLAUDE.md, the skills pull's end date is clamped back by SKILLS_LAG_DAYS, so these counts cover a narrower window than the report's spend period. A caller comparing skills counts against report-period commits — an obvious thing for an agent to do — draws a wrong conclusion with nothing to warn it.
Suggested: "Per-developer Claude skills usage by product (claude_code, chat, …) — skills invoked and distinct skills, for a report. Activity volume only, no cost; visible for all developers. Covers a window ending a few days before the report period, due to the Analytics endpoint's data lag."
…, cut duplication Critical: LIMIT was applied in SQL, before gating. The PR justified this as mirroring queryDeveloperStats, which was wrong — stripDevCost keeps rows and blanks fields, so a pre-gating limit costs it nothing, whereas stripModelCost drops rows. Spending the row budget on rows that are then deleted had three consequences: a caller's own entitled rows became unreachable behind an alphabetical prefix (breaking cost-visibility.ts's explicit "own cost is always visible" guarantee); `count` became an oracle for the ordinal positions of hidden rows, recoverable by sweeping `limit`; and truncation could cut mid-developer, so a visible developer's summed per-model cost silently disagreed with their cc_total_cost. Both queries now bind MAX_ROWS in SQL and apply the caller's limit in JS after gating, and report `truncated`. queryModelUsage also resolves visibility before the data query and returns early when the caller may see no costs at all, so that path performs no table read (its execute count drops 2 → 1). cost was emitted unqualified while cc_model_usage.cost holds cents — every UI consumer divides by 100 at render, but an MCP caller has no such convention, so a model would report $1250.00 for $12.50. Renamed to cost_cents. Also stated the unit for cc_total_cost in query_developer_stats' description rather than renaming that field, which would break existing callers of a shipped tool. Other review findings: - Return cost_visible so an agent can distinguish "you may see no costs" from "this report has no usage". resolveCostVisibility already computed canSeeAnyCost and the tool discarded it; an authenticated user with no user_mappings row is a documented population that hit exactly this. - Extract gateModelRowsByLogin into cost-visibility.ts. The group-by-login + stripModelCost dance was duplicated byte-for-byte in the org route, and this policy has already changed once. Kept the indirection through stripModelCost rather than a local filter, which is what would have silently diverged. - Pin the SQL invariants. db.execute is mocked everywhere, so deleting the INNER JOIN developer_stats or either LOWER() left the suite green — while the join is what prevents the wider-population discrepancy that shipped once already. Added text-level assertions mirroring org-model-usage.test.ts, plus the login case-folding and the MAX_ROWS binding. - Drop three tests that re-covered branches already asserted by an exact-value toEqual, and the skills wiring test, which defended a failure mode that cannot exist for an ungated handler. - Soften the "hidden is indistinguishable from no usage" claim in stripModelCost's doc and the tests. It holds within one payload but not across the tool surface: stripCostFields deletes cc_total_cost while the column is NOT NULL DEFAULT 0, so key presence separates idle from hidden in one call. The honest guarantee is that amounts and model mix are withheld. - Fix stripModelCost's summary line, which stated its condition backwards. - Document the login filter as case-insensitive in both tool descriptions: the sibling tools match exact case, and the asymmetry was invisible from tools/list. - Say the cost feed covers all Anthropic surfaces, that skills `product` includes chat/cowork, and that the skills window ends before the report period because of the endpoint's lag — all three would otherwise be misattributed by a model that only ever sees the description. - Trim comment recaps of rationale owned by cost-visibility.ts and org.ts to pointers, and scope resolveCostVisibility's doc to its actual two callers. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Review addressed —
|
GLOOK-37
The MCP server exposed only two Claude Code fields per developer —
cc_total_costandcc_requests. The per-model and per-product breakdowns that GLOOK-30 ingests were reachable from the REST routes and three UI surfaces, but not over MCP, because the MCP server is a separate query layer that was never extended to read them.Two new tools, taking the server from 14 to 16:
query_model_usagecc_model_usagequery_skills_usagecc_skills_usageBoth accept
report_id,login,limit, matching the siblingquery_*tools.Why two tools instead of widening
query_developer_statsCallers that don't want breakdowns don't pay for them —
query_developer_statsreturns up to 500 developers, and per-model rows would multiply that. More importantly it puts the visibility asymmetry at the tool boundary rather than burying two different gating rules inside one response shape.requestsis gated, not justcostrequestsisn't currency, but summing a developer's per-model requests reconstructs the gatedcc_requestsexactly — same window, merely grouped by model. Gating only the dollar figure would reopen whatstripDevCostcloses. This is the leak the PR #64 whole-branch review caught, and there's a test asserting a hidden developer's requests sum to zero.Gating reuses
stripModelCost, which drops a hidden developer's rows wholesale rather than blanking amounts, so "cannot see this developer's cost" stays indistinguishable from "has no Claude usage".querySkillsUsagetakes norequesterparameterSkills usage is ungated by policy (see
CC_FIELDSincost-visibility.ts— skill counts convert to no currency, so they sum to no gated figure). Omitting the parameter entirely makes that a compile-time fact rather than a convention a later edit can quietly break.Shared fail-closed helper
Extracted the visibility resolution out of
queryDeveloperStatsintoresolveCostVisibility, so the fail-closed rule has one implementation rather than one per tool. The admin / no-requester short-circuits stay ahead of thereportOrglookup —mcp-cost-gating.test.tsasserts exactdb.executecall counts to prove the fail-closed path performs no team query, so that ordering is load-bearing.Verification
Unit tests mock
db.executeand therefore cannot see the SQL at all, so I went further:INNER JOIN developer_statsexcludes a login with no row for the report, thatLOWER()matchescarolagainstCarol(an exact-case join would silently drop it), and that DECIMALcostreturns12.5rather than"12.50".glooker.db— real rows out,count: 0with no requester.(a) => queryModelUsage(a)makes it fail, which is the point: that bug fails closed and silently returns nothing for everyone, and no test calling the query function directly would notice.npm test: 104 suites, 934 tests, 9 snapshots — all passing (+8 from this branch).Notes for review
LIMITapplies before gating, so a team member can receive fewer rows than their limit when hidden developers occupy some. This mirrorsqueryDeveloperStatsexactly; kept consistent rather than diverging.developer_statsfor the reasons documented above the equivalent queries inreport/org.ts— an unjoined read reports a wider population than every other per-report figure.scripts/seed.tsalready seeds both tables.🤖 Generated with Claude Code