Refused
Access states exactly what is enforced for reads and what is not. Sign in is readable without a session, deliberately, because a page that explains a lock must not be behind it.
unconfigured was refused before it rendered, and the response carried none of its data. Everything below describes the rule that refused it.
Permissions
Every row behind this figure was written by a demo seed so that this page would have something to render. The arithmetic may be exact and the figure is still not a fact about the business. Do not quote it.
Each of the 46 writes below resolves a signed-in ThcUser first, refuses if there is not exactly one active record behind the session, then checks the capability for that person's role, then refuses any database that is not local dev. All but one live in operations.ts, and for those the audit row carries that person's actorId in the same transaction as the change itself. The exception is importBankExport in finance-actions.ts, which changes a file and runs the reconciliation pipeline rather than writing a database record. It is gated exactly like the others and it is recorded on WEAKER TERMS than any of them, which are stated in full below rather than rounded up to "audited".
Every import that reaches the finance data directory has an attributed audit row committed before the file is written, because that insert is a precondition of writing it: if the row cannot be recorded the import is abandoned. The outcome row written afterwards, carrying what the reconciliation pipeline said, is best effort and is lost if the process dies mid-import. This is weaker than the transactional guarantee every other write here has, where the row and the change commit together or neither does. A started row with no outcome beside it means somebody has to look at tools/finance/data by hand. The two actions to look for on /audit are BANK_EXPORT_IMPORT_STARTED and BANK_EXPORT_IMPORTED, or BANK_EXPORT_IMPORT_FAILED when the pipeline refused to publish and the upload was rolled back. Both carry the operator's actorId, the export file name and the state of the finance data directory either side of the attempt.
Reads are refused on this request. Refused: authentication is not configured, so this console cannot tell who you are, and DATABASE_URL does not point at the local dev database on 127.0.0.1:55432, so what it would render is the real business. Reads without credentials are allowed against local dev only. An unconfigured console pointed anywhere else is an app deployed with the keys forgotten, which serves everything to whoever has the URL. Set NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY and restart.
Four layers, each answering exactly one question. It is layered rather than checked on every page because this console has thirty five pages and gains one most weeks: a rule copied to the top of each page is correct the day it is written and wrong the first time somebody forgets, and the forgotten one is invisible because the rest look right. Only one of the four answers before a query RUNS rather than before it is delivered, and it is the one nearest the data.
| Layer | Asks | What it cannot catch |
|---|---|---|
| Middleware src/middleware.ts | Is this request allowed to reach a page at all. It runs on every request, including the payload fetches a client-side navigation makes, and it is the only layer that answers BEFORE anything renders. It turns away a caller with no Clerk session, and an unconfigured console pointed at anything but the local dev database. Both responses are a redirect with an empty body. | Anything needing the database. It runs on the Edge, which has no Prisma, so it cannot tell an active operator from a stranger or from somebody switched off, and it cannot name a capability without becoming a second copy of the table below in another runtime. |
| The root layout src/app/layout.tsx | Is this a valid, active operator. Every route renders inside it, so one check covers all of them, including a page added next week. When it refuses it redirects here, so a browser never displays the page it asked for. | It cannot stop the page beneath it from rendering, which was measured rather than assumed: Next renders the page segment alongside the layout, so the RSC payload in the response still carries that page's rows, and so does the streamed body of its redirect. A browser is turned away and a hand-written request is not. It also does not re-run on a client-side navigation between siblings. That is why the layer below it exists: nothing now rests on this one alone. |
| The data access path src/lib/prisma.ts | May this request cause a query to run. A client extension on the one client every page imports, running inside the call and before the query is sent. It asks the same rule the shell asks and refuses the query outright when the answer is no, so an unregistered or deactivated caller does not get rows withheld from a payload, it gets no rows. It fails closed: a session that cannot be resolved at all refuses too. Jobs and the suite read through a named pass that refuses to open from inside a request. | A capability. It does not know which page is asking and must not, so the three rows below still check themselves. It cannot gate what is not a query, and this console renders reconciliation figures compiled into the bundle. It governs this client only, so a new PrismaClient() anywhere else is outside it. And it refuses before the first query rather than before the first byte, so a raw client gets a partial document with an error where the data was, not the empty body the middleware produces. |
| The page requireRead(...) | May this person read this particular thing. The only layer that names a capability, the only one that re-runs on every render, and the only one whose check is sequential with the queries it guards: it returns before the first database call in the same function, so nothing is read to be discarded. | Pages not in the table below. That is deliberate rather than missing: on every other page CLAUDE.md gives both roles the same answer, so a check there would always pass and still need maintaining forever. |
| Route | Needs | sales | ops_coordinator | owner | Why this one |
|---|---|---|---|---|---|
| /audit and everything under it | AUDIT_READ | refused | refused | may read | The role table in CLAUDE.md withholds the audit log from an ops coordinator by name. This is the only read rule here that is quoted rather than judged. |
| /finance and everything under it | FINANCE_READ | refused | may read | may read | The reconciled bank ledger and everything built on it. A separate capability from AUDIT_READ so a refusal names the page somebody actually opened, and so the two answers can change independently. |
| /accounting and everything under it | FINANCE_READ | refused | may read | may read | The reconciled bank ledger and everything built on it. A separate capability from AUDIT_READ so a refusal names the page somebody actually opened, and so the two answers can change independently. |
| /rails and everything under it | FINANCE_READ | refused | may read | may read | The reconciled bank ledger and everything built on it. A separate capability from AUDIT_READ so a refusal names the page somebody actually opened, and so the two answers can change independently. |
With Clerk configured: nothing except /sign-in and this page, both public deliberately, because a page explaining a lock must not sit behind it. Neither renders business records, and the staff table below is not rendered at all without an operator. With Clerk NOT configured, reads are open against 127.0.0.1:55432 and every other DATABASE_URL is turned away in the middleware, before a page renders. That single opening is argued in readGate in lib/auth.ts: a laptop reading demo rows leaks nothing worth a lock, and the same binary reading Supabase leaks the company, so the database is not a proxy for the question, it is the question. Every page it applies to says so on the page. What a caller with no valid operator can obtain from a page that does render, this one, is what is compiled into the bundle rather than fetched: the permission model itself, the layer descriptions above, and on other routes the reconciliation figures in data/reconciliation.json. Every row that comes out of the database is refused at the query, not at the response.
Every write is refused before the capability table is consulted at all, so the question of which role you would have does not arise. This is the message any form in this console will give you, produced by calling the real check rather than written out here:
Refused: authentication is not configured, so this console has no operator and will not write. Set NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY and CLERK_SECRET_KEY and restart. Until then every write is refused rather than attributed to nobody, because a console that writes without an identity produces an audit trail that cannot answer who.
Taken from the THC staff roles table in CLAUDE.md, which is the source: the owner has everything, and an ops coordinator has everything except pricing changes, file archive, staff management and the audit log. It is written in code as those exclusions rather than as two lists, so a capability added later is granted to both roles unless somebody deliberately withholds it. Two more are withheld than that table names, because it predates them both: COMMISSION_APPROVE, the only gate between an earned commission and a payable to a named person, and FINANCE_WRITE, which replaces the dataset every financial figure here is derived from. Both are judgements argued in rbac.ts and reversible by deleting one line each.
| Capability | sales | ops_coordinator | owner | Gates these |
|---|---|---|---|---|
| Change pricing PRICING_WRITE Contract price per case, and the global settings the price cascade reads. | refused | refused | held | setSetting, setAccountPrice, recordMenuPrice |
| Manage staff STAFF_MANAGE Issuing rep codes and registering drivers. Staff logins are minted in Clerk, not here. | refused | refused | held | createRep, createDriver, recordShiftWageRate |
| Read the audit trail AUDIT_READ The full history of who changed what, including pricing before and after values. | refused | refused | held | Reading /audit |
| Read the finance ledgers FINANCE_READ The reconciled Mercury bank ledger and every view built on it: the accounting planes, cash-basis statements, receivables ageing, the chart of accounts and the per-counterparty ledgers. | refused | held | held | Reading /finance, /accounting, /rails |
| Replace the bank ledger FINANCE_WRITE Importing a Mercury transactions export and re-running the reconciliation pipeline, which regenerates the dataset behind /finance and /accounting and therefore every financial figure this console states. | refused | refused | held | importBankExport |
| Archive files FILE_ARCHIVE Account file archival. No operation in this console does this yet. | refused | refused | held | nothing yet: nothing in this console checks it |
| Work invoices INVOICE_SEND Drafting an invoice for a delivered order, sending a draft, marking a sent invoice paid. | refused | held | held | draftInvoiceForOrder, sendInvoice, markInvoicePaid |
| Work the task board TASK_WRITE Creating, assigning and completing tasks, moving a card between columns on the board, writing in a task thread, drawing and releasing a dependency between two tasks, and promoting a suggestion into a task. Promotion is here rather than under SUGGESTION_WRITE because what it does is put work on the board, and gating it on the suggestion capability would let somebody who may not create tasks create one through a different door. A thread entry can never be edited or removed once written. | held | held | held | completeTask, createTask, assignTask, setTaskStatus, promoteSuggestion, addTaskNote, drawTaskDependency, releaseTaskDependency |
| Work the pipeline CRM_WRITE Logging activities, moving deals, setting next steps, creating opportunities. | held | held | held | logActivity, completeActivity, setDealStage, setNextStep, createOpportunity |
| Record compliance COMPLIANCE_WRITE Cold chain readings, incidents, certificates. No operation in this console does this yet. | refused | held | held | nothing yet: nothing in this console checks it |
| Plan deliveries DISPATCH_WRITE Opening a delivery run, putting orders on it or taking them off, offering a run to a driver. | refused | held | held | createDeliveryRun, assignOrderToRun, removeOrderFromRun, offerRunToDriver |
| Set where and when we serve SERVICE_AREA_WRITE Opening or closing a zip to the signup funnel, and the standing weekly schedule for a cafe account. | refused | held | held | setZipServiceable, setCafeSchedule |
| Clear commissions for payout COMMISSION_APPROVE Approving an earned commission so a payout run can settle it, and voiding one with a reason. Neither moves money; approval is the gate that decides what becomes payable. | refused | refused | held | approveCommissionEvent, voidCommissionEvent |
| Record who closed an account ATTRIBUTION_WRITE Naming the closing rep on an account and stating how it is known, OPPORTUNITY from a won deal or RECORDED by a person. This names who may be paid; it pays nobody. | refused | held | held | recordAccountAttribution |
| Record what an event movement was worth EVENT_MONEY_WRITE Marking an EVENT_OUT inventory movement PAID with an amount, FREE, or UNRECORDED, and naming the counterparty account. No commission follows: the event line has no ratified rate. | refused | held | held | recordEventConsideration |
| Record what an event did with its stock EVENT_STOCK_WRITE Filing an allocation, a return, or a sale, trade, comp, staff drink or spoilage at the venue against an event settlement. It records one movement as entered and balances nothing: the unaccounted remainder is reported, never absorbed. | refused | held | held | recordEventStockMovement |
| Record a supplier document PURCHASING_WRITE Transcribing a supplier invoice, credit note or statement, its lines, and which claim it settles. A document whose lines do not sum to its stated total is refused rather than rounded, and a settlement keeps the claim it refutes, marked superseded. | refused | refused | held | recordPurchaseDocument, settleConflictWithDocument |
| Keep a composed view VIEW_COMPOSE Saving a named, ordered list of panel ids at /spaces, and retiring one. It stores which questions to ask and in what order; every answer is recomputed on every render, and no figure is written anywhere. | refused | held | held | saveView, retireView |
| Use the suggestion boxes SUGGESTION_WRITE Writing in the internal box, voting for a suggestion in either box, and recording a review: being looked at, answered no, or not a suggestion at all. It commits nobody to anything and creates no work. | held | held | held | submitSuggestion, voteForSuggestion, reviewSuggestion |
| Record timed work LABOUR_WRITE Timing a named piece of a route shift, clocked or stated after the run, and recording timed work no segment names yet. Re-timing supersedes the prior measurement and keeps it readable. It writes no rate and moves no money. | refused | held | held | recordSegmentTiming, recordStandaloneTime |
| Record kitchen measurements RECIPE_WRITE Recording what a menu component costs (as a point or an honest band, each edge citing its own document), what one bought unit yields, and how much goes into a drink. It sets no menu price and moves no money. | refused | held | held | recordComponentCost, recordComponentYield, recordServingQuantity |
These are the ThcUser rows, and they are now the access list this console actually reads. A Clerk login matched against clerkUserId is what turns a row here into an acting role, so a person with no row can sign in and do nothing, and a row with active off is refused rather than shown less.
| Name | Role | Status | Capabilities | Withheld | Since | |
|---|---|---|---|---|---|---|
Not shown. This page is readable without a session so that a refusal can be read, and the roster is not part of that explanation: it is the access list itself, with names, emails and roles. Sign in and it appears. | ||||||
Stated plainly, because a permission system that overstates its own coverage is the most dangerous kind. Each of these is a real hole, not a rough edge.
Every page now requires a valid active operator, enforced in the root layout and again in the data access path rather than thirty five times. Only /audit, /finance and /accounting additionally check a capability, because those are the surfaces where CLAUDE.md gives the two roles genuinely different answers. Everywhere else, both roles read the same thing, which is what the role table says and is therefore not a hole. It does mean the console has no notion of a read that is narrower than a role: there is no way to say a coordinator may see one account and not another.
Measured, not assumed. Next renders the page segment alongside the layout, so a layout that returns a refusal instead of children still produces a response whose RSC payload carries the page rows, and the streamed body of its redirect does too. What used to rest on the layout alone was UNREGISTERED and DEACTIVATED, people holding a real Clerk session inside the organisation, and for those a browser was turned away while a hand-written request could read the body. That is closed: the check now lives in the data access path itself, in lib/prisma.ts, where a refused request causes no query to run at all and there are no rows in any payload to withhold. The layout keeps redirecting because a browser should not display a page it may not have, but it is no longer the thing standing between those two states and the business.
Next.js re-renders a layout when the tree above a route changes, not when moving between siblings, so a browser that already holds the shell can fetch a sibling page payload without the layout check running again. That no longer decides anything: the sibling page still has to fetch its rows through lib/prisma.ts, which resolves the session on every call, so a person deactivated a minute ago is refused on the next navigation whether or not the layout ran. Middleware still refuses a request with no Clerk session at all.
lib/prisma.ts refuses a query when readGate refuses the request, which is the same rule the shell applies and no more. It deliberately does not know which page is asking: a path-to-capability table there would be a second copy of this file in a module that cannot see a route, so /audit, /finance and /accounting still check themselves. Three further limits, stated so nobody reads silence as coverage. It cannot gate what is not a query, and /accounting renders reconciliation figures compiled into the bundle. It only governs the client it exports, so a new PrismaClient() constructed anywhere else is outside it, which is why the one ungated client it hands auth.ts is held to a single importer by a test. And the refusal happens before the first query rather than before the first byte, so a raw client gets a partial document with an error where the data was, rather than the empty body the middleware produces.
They legitimately have no operator and legitimately need data, so the pass exists rather than being improvised later as a flag somebody forgets to unset. runUnattended(reason, work) opens an AsyncLocalStorage scope for the length of one call and refuses to open at all from inside a Next request, so a server action cannot take it to shake off a refusal. The test suite gets a process-wide arming instead, because a Vitest hook cannot wrap the file after it in a scope, and that arming refuses outside Vitest exactly as signInForTests does. Both are real openings: anybody who can run code in this process as a job can read everything, which is the same authority a job already has by holding DATABASE_URL.
This is a decision, not an oversight. A console with no credentials has no sign-in flow to send anybody to, so refusing every page would leave an operator staring at a locked door with no way to read why, and a laptop reading a throwaway Postgres on 127.0.0.1:55432 is not leaking anything. Unconfigured pointed at any other DATABASE_URL is the actual hole, an app deployed with the keys forgotten serving the whole business to whoever has the URL. That is refused in the middleware rather than in the layout, so no page renders and the response body is empty. The open state is stated on every page it applies to rather than inferred from a quiet badge, because a reader who does not know the gate is off will assume it is on.
Membership of the Clerk instance grants nothing on its own: the role and the active flag live on ThcUser, and a Clerk user with no matching row is refused every write by name. That is deliberate, but it does mean the Clerk instance itself is not the access list. Who may reach the app at all is decided by Clerk; who may do anything is decided by the ThcUser table.
Neither operation exists in this console yet. They are named now so the role table matches CLAUDE.md, and so the day one is built the question of who may run it is already answered.
importBankExport resolves an operator, checks FINANCE_WRITE and refuses any database that is not local dev, in that order, so who may run it is answered the same way as every other write. It IS audited now, and the guarantee is deliberately weaker than the one next door. It touches no database, it writes a CSV and runs the reconciliation pipeline as a child process, so there is no transaction to commit a row inside. Two rows bracket the work instead: the attempt is recorded BEFORE the file is written and that insert is a precondition, so an unattributed import cannot happen, and the outcome row that follows is best effort and is lost if the process dies mid import. A started row with no outcome is therefore a signal to go and look at the data directory, not noise. The full statement is BANK_IMPORT_GUARANTEE in lib/finance-audit.ts. Editing that directory by hand still leaves no row at all: this audits the console path to the ledger, not the ledger.
The role table withholds four things from an ops coordinator and the bank ledger is not one of them, and this file grants by default and withholds deliberately, so both roles hold it. A coordinator also needs receivables ageing to work the invoice queue they already hold INVOICE_SEND for. The unsettled part is that /finance renders the raw Mercury export, which names people who were paid. If that should be owner only, adding FINANCE_READ to WITHHELD_FROM_OPS_COORDINATOR in rbac.ts is the entire change and ten pages start refusing by name.
This map governs the operations in this app only. Clerk roles in the ops dashboard and bearer auth on the API are separate enforcement, and changing this file changes neither.
Every write also calls assertWritable(), which refuses unless the database is local dev. Reading was the first of the four conditions its own comment sets and it is now met, which leaves three. Clerk must actually be configured wherever this runs, and it is not configured here: the read gate leans on assertWritable()'s own signal to decide that an unconfigured console may still read a laptop database, so removing the guard while the unconfigured state is reachable would delete the thing that makes that opening safe. The money-critical operations must still be the API's and not have grown a second path here. And there has to be a deployment story: a host, an audit of who can reach it, and a way to revoke faster than editing a row by hand. Until all three, deleting that line is how this console writes to production.