Skip to content

The budget gate guarded a table with no reader - #256

Merged
satvikOS merged 1 commit into
mainfrom
budget-capability-gate
Aug 25, 2026
Merged

The budget gate guarded a table with no reader#256
satvikOS merged 1 commit into
mainfrom
budget-capability-gate

Conversation

@satvikOS

Copy link
Copy Markdown
Collaborator

The claim, measured before anything was written

The premise held. Every number below was taken from this tree, not assumed.

Claim Measured
budget.override exists and is checked Yes — lib/admin/capabilities.ts:176, OSE_DIRECTOR, and on CAPABILITIES_REQUIRING_A_STATED_REASON
…but guards a table with no reader Yes. ONE requireCapability("budget.override") site in the whole tree: adminAdjustBudget, writing Budget.allocatedCents. db.budget.* appears twice in non-test source — that action's own findUnique and update. allocatedCents appears nowhere outside schema.prisma and the preview seed.
the real finance authority has no capability gate Yes. grep requireCapability in orgs/[slug]/finance/actions.ts0 hits, across 7 BudgetLine write sites.
…and records no stated reason Yes. requireFinanceManager wrote an AuditEvent with outcome and no reason field at all.
the power is reachable institution-wide Yes. canManageFinance opened with if (isOseDirector(ctx, org.institutionId)) return true — every club, no seat required.

So every-listed-capability-is-wired.test.ts was green on budget.override vacuously: the declared control had no reader, and the thing with the readers had no control.

What changed

financeAuthorityOf (lib/rbac.ts) now answers where the authority came from rather than only whether it existed:

  • SEAT — the club's own ACTIVE President or VP of Finance
  • OSE_OVERRIDE — an OSE Director holding no seat in this club
  • null — everybody else

canManageFinance is now derived from it rather than restated, so the predicate that decides whether and the one that decides in what right cannot drift into two answers to one question. The seat is asked first: a Director who is also this club's treasurer is doing the club's own work and is not interrogated — and is still an override in the club next door.

requireFinanceManager routes an OSE_OVERRIDE through requireBudgetOverride (lib/finance-override.ts) for the four writes that change what a club may spend:

Action What it changes
Finance.EditLine adds a line, or re-budgets one that exists
Finance.CloseLine retires a line, taking its allocation off the plan
Finance.ReopenLine puts a retired allocation back on it
Finance.Import replaces the whole plan, including closing lines the sheet omits

That call is requireCapability("budget.override", { statedReason }) — the gate that already existed. It refuses on the server, writes a DENY row for a groundless attempt, and puts the operator's own sentence verbatim on the ALLOW row's reason, the field /admin/audit prints and searches. It is also threaded onto the club's own recordAdjustment metadata as overrideReason, so the next treasurer reads why OSE changed their line without holding audit.view.

Deliberately not gated, named rather than omitted

  • Finance.SaveForecast writes forecastCents — what a club expects to spend, never what it may.
  • Finance.PostLedger / Finance.ReverseLedger record that money moved. Both already refuse without a description, or a reason code plus a note over the same 12-character floor. Whether an OSE ledger posting is itself an override is a real question and a separate one — it is stated in finance-override.ts and asserted by name in the tests, so deciding it later is a decision rather than a discovery.

The UI extends the precedent, and is not the control

components/forms/ReasonField.tsx — the asterisk the onboarding decline already uses — gains an optional controlled mode, because the add-a-line form asks "did you mean to re-budget?" and resubmits itself, and React empties an uncontrolled box when a server action resolves. An uncontrolled field would have posted the confirmation with the reason blank.

The close dialog reuses the note it already refuses without rather than asking the same person the same question in two boxes — raising only its floor from 8 to 12 for an override, and saying which applies. Reopen was one unguarded click; for an override it now opens a dialog. A club officer sees none of this.

PR #214's decision is respected, not undone

adminAdjustBudget stays, exempted with its written reason and its standing control. What changed is the ground under the exemption: it is no longer "the only wiring of a listed capability", so deleting it would no longer weaken anything — it is now an ordinary product question about whether the console wants a form for a table with no readers.

Three sentences that said the resolution meant "delete this in the same diff", and the sweep header that recorded it as already deleted, are corrected. every-server-action-has-a-caller and every-listed-capability-is-wired both stay green.

The guard is a class control

spending-authority-is-gated.test.ts walks the TypeScript AST of every tracked source file, finds each Prisma write on BudgetLine/Budget whose payload carries an allocation column (plus every create and delete), resolves it to the outermost enclosing function, and requires that function to reach a gate. A file-level check would pass the moment the file contained a gate anywhere — which is exactly where somebody working on budgets is already typing.

It also derives BUDGET_AUTHORITY_ACTIONS from the source and compares it with the declared list in both directions, so a gated write under a name nobody listed — a gate present and inert — fails too.

Payloads only, never where: postToLedger names closedAt: null as a compare-and-swap, and counting filters would read every guarded movement as a retirement (the mistake ledger-single-writer.test.ts had to correct in its own regex).

Five mutations, each md5-verified as applied and restored

Mutation Result
strip requireCapability from the gate guard test 1 red, endpoint test 20 red
unwire requireBudgetOverride from the chokepoint "routes an OSE override" red
new ungated write, same file red, naming grantExtraBudget() at its line
new ungated write, new file red, naming bumpBudget() at its line
gated write under an unlisted action name red, naming "Finance.RaiseLine"

Neighbouring branches, asked deliberately

  • A Director whose seat here has expired is an override — tested. Reading storedStatus would have let a past treasurer keep editing with nothing recorded.
  • A third model carrying budgetedCents would be invisible to the walk, so the scan reads schema.prisma and fails if one appears.
  • Raw SQL sits under $allOperations; no AST walk keyed on the model API sees it. Checked — from the SQL itself, because a file-level grep reported lib/tenancy/registry.ts, which mentions both only in prose.
  • Delegation is deliberately not honoured: the finance write resolves no delegated authority of its own, and opening that door would admit a delegate the command then refuses.
  • OSE_STAFF cannot reach the override path via financeAuthorityOf; asserted anyway, so a future widening of that predicate is caught by the capability table rather than silently handed institution-wide budget power.

Concurrency

The existing db.auditEvent.create inside requireFinanceManager is untouched — the capability check is added after it, and no audit write site was restructured. A groundless override leaves two rows: Finance.* saying the authority was there, and Admin.budget.override / DENY saying the override was refused. Two true statements; that Finance.* row has never meant "the write happened".

Verification

  • tsc --noEmit307 errors, parity with pristine main (all from a stale generated Prisma client)
  • next lint → clean
  • jest3 failed suites, 5578 passed — the same three that fail on pristine main (connectors/audience, nothing-manufactures-the-member-seat, identity/onboarding-form, all Object.values(<PrismaEnum>) against the stale client)
  • 51 new tests across 3 files, plus 9 in rbac.test.ts

Not done, and not claimed

No Playwright spec. The finance page has no e2e harness to extend, and one written blind would be an overclaim. The server-side proof is an-ose-override-with-no-reason-writes-nothing.test.ts, which posts to the exported server actions with no form and no browser and asserts both the refusal and that Prisma was never asked to write anything.

Budget is still a table nothing reads. This diff binds the capability to the money that moves; it does not decide the console's Budget form. That remains #214's open product question.

🤖 Generated with Claude Code

MEASURED FIRST, because the premise could have been wrong. It was not.

`budget.override` — Director-only, on CAPABILITIES_REQUIRING_A_STATED_REASON —
had ONE requireCapability call site in the tree: `adminAdjustBudget`, writing
`Budget.allocatedCents`. Nothing in apps/web/src reads that table. The only
`db.budget.*` calls anywhere are that action's own findUnique and update, and
`allocatedCents` appears nowhere outside schema.prisma and the preview seed. So
`every-listed-capability-is-wired.test.ts` was passing on that id VACUOUSLY.

The power the capability NAMES was exercised somewhere else. Club money is
`BudgetLine`; every write to it went through `canManageFinance`, which returned
true for an OSE Director on EVERY club in the institution. Seven allocation
writes across `orgs/[slug]/finance/actions.ts`, zero requireCapability calls in
that file, zero stated reasons. A Director could rewrite any club's allocation,
retire any club's line, or replace a club's whole plan from a spreadsheet, and
the trail said only that somebody with finance authority did something.

THE FIX. `financeAuthorityOf` (lib/rbac.ts) now answers WHERE the authority came
from, not merely whether it existed: SEAT for the club's own ACTIVE president or
VP of Finance, OSE_OVERRIDE for a Director holding no seat in the club, null for
everybody else. `canManageFinance` is derived from it rather than restated, so
the two cannot drift into two answers to one question. The seat is asked FIRST,
so a Director who is also this club's treasurer is doing the club's own work and
is not interrogated — and the same person is still an override next door.

`requireFinanceManager` then routes an OSE_OVERRIDE through
`requireBudgetOverride` (lib/finance-override.ts) for the four writes that change
what a club may SPEND: Finance.EditLine, CloseLine, ReopenLine, Import. That is
`requireCapability("budget.override", { statedReason })` — the gate that already
existed, refusing on the server, writing a DENY row for a groundless attempt and
putting the operator's own sentence verbatim on the ALLOW.

NOT GATED, and each named rather than omitted: Finance.SaveForecast writes a
projection, never authority. Finance.PostLedger and Finance.ReverseLedger record
that money MOVED — both already refuse without a description, or a reason code
plus a note. Whether an OSE ledger posting is itself an override is a real
question and a separate one; it is stated in finance-override.ts and asserted by
name in the tests so deciding it later is a decision rather than a discovery.

THE UI EXTENDS THE PRECEDENT, and is not the control. `components/forms/
ReasonField.tsx` — the asterisk the onboarding decline already uses — gains an
optional controlled mode, because the add-a-line form asks "did you mean to
re-budget?" and resubmits itself, and React empties an uncontrolled box when a
server action resolves. The close dialog reuses the note it ALREADY refuses
without rather than asking the same person twice, raising only its floor from 8
to 12 for an override and saying so. Reopen was one click; for an override it
now opens a dialog. A club officer sees none of this.

PR #214's DECISION IS NOT REOPENED. `adminAdjustBudget` stays, exempted with its
written reason. What changed is the ground under the exemption: it is no longer
"the only wiring of a listed capability", so deleting it would no longer weaken a
control. Both stale sentences that said "delete this in the same diff" are
corrected, along with the sweep header that recorded it as deleted.

THE GUARD IS A CLASS CONTROL, not a check of today's call site.
`spending-authority-is-gated.test.ts` walks the AST of every tracked source file,
finds each Prisma write on BudgetLine/Budget whose payload carries an allocation
column (plus every create and delete), resolves it to the OUTERMOST enclosing
function, and requires that function to reach a gate. It also derives
BUDGET_AUTHORITY_ACTIONS from the source and compares it with the declared list
in both directions, so a gated write under a name nobody listed — a gate present
and inert — fails too. Payloads only, never `where`: postToLedger names
`closedAt: null` as a compare-and-swap, and counting filters would read every
guarded movement as a retirement.

FIVE MUTATIONS RUN, each md5-verified as applied and restored:

  strip requireCapability from the gate    → guard test 1 red, endpoint test 20 red
  unwire requireBudgetOverride             → "routes an OSE override" red
  new ungated write, same file             → named grantExtraBudget() at its line
  new ungated write, NEW file              → named bumpBudget() at its line
  gated write under an unlisted action     → named "Finance.RaiseLine"

NEIGHBOURING BRANCHES, asked deliberately: a Director with an EXPIRED seat here
is an override (tested — reading storedStatus would have let a past treasurer
keep editing). A third model carrying `budgetedCents` would be invisible to the
walk, so the scan reads schema.prisma and fails if one appears. Raw SQL sits
under $allOperations and no AST walk keyed on the model API sees it, so that is
checked too — from the SQL itself, because a file-level grep reported
lib/tenancy/registry.ts, which only mentions both in prose.

Delegation is deliberately NOT honoured: the finance write resolves no delegated
authority of its own, and opening that door would admit a delegate the command
then refuses.

CONCURRENCY: the existing `db.auditEvent.create` in requireFinanceManager is
untouched — the capability check is added AFTER it, and no audit write site was
restructured.

307 tsc errors (parity with main), lint clean, jest 3 failed suites (the same
three that fail on pristine main), 5578 passed.

NOT DONE, and not claimed: no Playwright spec. The finance page has no e2e
harness to extend and one written blind would be an overclaim; the server-side
proof is `an-ose-override-with-no-reason-writes-nothing.test.ts`, which posts to
the exported server actions with no form and asserts no statement was issued.

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

@greptile-apps greptile-apps Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

satvikOS has reached the 50-credit limit for trial accounts. To continue receiving code reviews, upgrade your plan.

@coderabbitai

coderabbitai Bot commented Aug 25, 2026

Copy link
Copy Markdown

Warning

Review limit reached

Next included review available in 20 minutes.

View limit details

Limit details: You’ve used all 10 included reviews currently available.

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 0159781a-82e5-4638-bb57-86e4380aeef3

📥 Commits

Reviewing files that changed from the base of the PR and between 08932a3 and 9ed0003.

📒 Files selected for processing (15)
  • apps/web/src/app/(app)/admin/actions.ts
  • apps/web/src/app/(app)/orgs/[slug]/finance/actions.ts
  • apps/web/src/app/(app)/orgs/[slug]/finance/an-ose-override-with-no-reason-writes-nothing.test.ts
  • apps/web/src/app/(app)/orgs/[slug]/finance/page.tsx
  • apps/web/src/components/finance/AddBudgetLineForm.tsx
  • apps/web/src/components/finance/BudgetUpload.tsx
  • apps/web/src/components/finance/FinanceDashboard.tsx
  • apps/web/src/components/forms/ReasonField.tsx
  • apps/web/src/lib/__tests__/an-override-of-a-clubs-budget-states-a-reason.test.ts
  • apps/web/src/lib/__tests__/every-server-action-has-a-caller.test.ts
  • apps/web/src/lib/__tests__/spending-authority-is-gated.test.ts
  • apps/web/src/lib/finance-override.ts
  • apps/web/src/lib/finance.ts
  • apps/web/src/lib/rbac.test.ts
  • apps/web/src/lib/rbac.ts

Comment @coderabbitai help to get the list of available commands.

@satvikOS
satvikOS merged commit c28c64e into main Aug 25, 2026
6 checks passed
@satvikOS
satvikOS deleted the budget-capability-gate branch August 25, 2026 04:30
satvikOS added a commit that referenced this pull request Aug 25, 2026
…te that (#264)

MAIN IS RED AT c28c64e. Two PRs that were each green apart broke it together,
which is the shape CI cannot see: every branch is tested against the main that
existed when it ran, and neither #254 nor #256 could observe the other.

#254 made `recordAuditEvent` READ THE TENANT'S LAST ROW to chain this one to it,
and wrap that read-and-write in a transaction when handed the top-level client
(`audit-record.ts:653`). #256 landed two suites whose `@/lib/db` doubles were
written when a refusal wrote exactly one `auditEvent.create` and nothing else.
The result is `tx.auditEvent.findFirst is not a function`, thrown from inside the
capability guard, so a test asserting the refusal MESSAGE got a TypeError and the
server action returned "Something went wrong on our side".

This is the concrete form of the cost recorded in task #32: the second round trip
does not just add latency, it CHANGES THE CONTRACT every caller's double must
satisfy.

Three changes, all to the doubles, none to what the tests claim:

`auditEvent.findFirst` returning `null` — the honest answer. These fixtures have
no prior row, and an unchained first row is exactly what the production code
handles when a tenant's log is empty.

`$transaction` EXECUTES its callback instead of throwing. It was a tripwire
meaning "a refused action reaches no write", which was true when a refusal wrote
one row and is false now that a refusal legitimately opens a transaction to chain
its DENY row — so the tripwire was firing on the very row the gate exists to
write. The `budgetLine.create`/`.update` traps stay, so a written line still
fails the test loudly, which is the claim that actually matters.

The two positive cases now assert `["budgetLine.create"]` rather than
`["$transaction"]`. Not a weakening: the action reaches the real write and now
NAMES it, where "it got as far as opening a transaction" was the vaguer claim
that only held because the double threw before its callback ran.

Measured against the documented baselines: 3 failed suites / 5698 passed — the
same three stale-generated-client suites that fail on pristine main — and tsc at
307, the baseline exactly.

Co-authored-by: Claude <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.

2 participants