Field-Level Data Visibility
roles.md answers Action × Role — can a Manager create a project, approve time off, cancel a liability. It does not answer a narrower, equally load-bearing question: which specific fields (a dollar figure, a rate, a deadline) does a given response actually carry for a given role. Those answers have lived only in scattered prose across CLAUDE.md and in the code itself, which is how #2092 (the manager-visible LER ratio) and #2095 (the manager-accessible person page) shipped without a single edit to roles.md.
This page is internal-facing. It names real column names, endpoint names, and payload keys, so it must never be linked from or folded into docs/logic-underneath.md — that page is prospect-facing and must never mention internal vocabulary. This page is not in the VitePress navigation and is not meant to be; like docs/reference/schema/columns.md, it is a working reference for whoever is changing the code.
This page documents the current boundary. It does not change it. Every row below was verified against the code as of the date this page was written; if you find a mismatch while reading it, the code is truth and this page is stale — fix the page, and if the mismatch looks like an unintended leak, stop and open a separate issue rather than folding a permission fix into a docs change.
Enforced by tests/data-visibility.test.js. That test parses the table below and checks a live response against it for a curated set of routes — so a field that starts leaking, or a table edit that no longer matches the code, fails npm test rather than waiting to be noticed.
Field × Role
| Field | Where it appears | Owner | Admin | Manager | User | Note |
|---|---|---|---|---|---|---|
Hourly cost rate (rate_history.hourly_cost) | Rates tab, Person page, Direct labor tab, cost/P&L calculations | ✅ | ✅ | ❌ | ❌ | /console/users (all four tabs, including Rates and Direct labor) is gated isOwnerLevel(user.role) at the top of router.get('/users', ...) in lib/console-routes.js. On /console/people/:userId, person.hourly_cost is set only if (isOwner) in that route's handler — the field is structurally absent from a manager's payload, not merely hidden — and renderConsolePerson (lib/console/render.js) separately gates the whole Role/Employment type/Discipline/Email/rate <dl> block on isOwnerLevel(user.role) (issue #2095). Double-gated by design; see the note on Mutation 1 below. |
contract_value | Project detail Financials card (/console/p/:id), New-project form, Reports, Forecast, Proposals | ✅ | ✅ | ❌ | ❌ | vm.contract_value is set only if (isOwnerLevel(user.role)) in the /p/:projectId handler (lib/console-routes.js); renderProjectDetail renders the Financials read row only if (project.contract_value !== undefined) (lib/console/render.js) — a single enforcement point (the route), trusted by the renderer. At the MCP layer, get_project_info (mcp-server.js) adds response.contract_value only if (isOwnerLevel(requestingRole)). /console/reports and /console/users are separately whole-route owner-only. |
billing_rate | Same surfaces as contract_value | ✅ | ✅ | ❌ | ❌ | Same enforcement as contract_value: vm.billing_rate set only if (isOwnerLevel(user.role)) in lib/console-routes.js; rendered only if (project.billing_rate !== undefined) in lib/console/render.js; get_project_info (mcp-server.js) gates response.billing_rate the same way. |
LER dollar components (ler_revenue / ler_cost) | Monthly P&L, PM comparison rollups | ✅ | ✅ | ❌ | ❌ | Never leaves queryPmComparison's dollar fields for a Manager: attachPmProjectLer (lib/console-routes.js) builds req.console.user.pm_projects carrying { id, name, ler } only — the ratio, never ler_revenue/ler_cost. /console/reports (where the dollar components are rendered) is entirely isOwnerLevel(user.role)-gated at the top of the route. |
| LER ratio | Sidebar "My projects" nav, project detail header pill | ✅ | ✅ | ✅ (only for projects they PM) | ❌ | pm_projects is built in requireConsoleSession (lib/console-routes.js), scoped to p.pm_user_id === user.id, then enriched with .ler via attachPmProjectLer/queryPmComparison (issue #2092). A Manager who PMs nothing gets an empty array and the pill never renders anywhere for them. |
agreed_amount (top-level Liabilities overview) | Top-level Liabilities overview (/console/liabilities) | ✅ | ✅ | ✅ (only for liabilities on projects they PM) | ❌ | checkLiabilityReadPermission (liabilities.js) gates the whole list_liabilities endpoint to owner+manager; the handler then filters a Manager's results to managedProjectIds (projects where pm_user_id === the requester). |
agreed_amount (project detail Liabilities card) | Project detail Liabilities card (/console/p/:id) | ✅ | ✅ | ❌ (no PM exception on this surface) | ❌ | vm.liabilities on /p/:projectId is set only if (isOwnerLevel(user.role)) (lib/console-routes.js) — unlike the top-level list above, there is no PM-of-project exception here: a Manager who PMs the project still sees no Liabilities card on its own detail page. Verified directly: tests/console-liabilities.test.js uses a Manager fixture (Bob) who is PM of the project under test and still asserts "no liabilities card at all." |
Salary bands, discipline_levels, employer_cost_multiplier, users.level | Bands tab and Direct labor tab of /console/users | ✅ | ✅ | ❌ | ❌ | Whole-route gate: isOwnerLevel(user.role) at the top of router.get('/users', ...) (lib/console-routes.js) covers every tab, so a Manager never reaches the tab-dispatch logic at all. The underlying MCP endpoints (list_discipline_level_bands, set_discipline_level_band, set_employer_cost_multiplier, mcp-server.js) are console-only (absent from MCP_TOOLS) and independently owner-gated. |
| Revenue / cost / margin / profit figures | Monthly P&L, Forecast, project detail Financials sub-panels (Envelope budget, Accrued P&L, Forecasted P&L), Segmentation | ✅ | ✅ | ❌ | ❌ | /console/reports and /console/forecast are whole-route isOwnerLevel-gated. On /console/p/:id, vm.labor_budget / vm.forecast_pnl / the Accrued-P&L fields on vm.analytics are all set only inside the same if (isOwnerLevel(user.role)) block as contract_value (lib/console-routes.js). |
| Deadlines | Project detail config card, capacity burn-pace context | ✅ | ✅ | ✅ | ❌ | vm.deadline is set if (user.role !== 'user') in the /p/:projectId handler (lib/console-routes.js). At the MCP layer, get_project_info (mcp-server.js) adds response.deadline only if (canSeeDeadline(requestingRole)), where canSeeDeadline returns true for owner, admin, and manager (never user). |
| Hours, capacity, envelope figures | /console/capacity, /console/time, envelope tables everywhere | ✅ | ✅ | ✅ | ✅ (own hours only) | No dollar-denominated field is ever included in queryCapacityWeeks / computeAvailabilityForUser (lib/availability.js) — visibility is scoped by whose hours are shown, not gated by role. A user sees only their own; every other role sees the whole team. |
Reviewing a change against this contract
Before merging a change that touches a route, endpoint, or renderer carrying any field in the table above, ask:
- Does this add a field to a payload a lower role can reach? Trace the object that reaches the renderer (or the JSON a route returns) all the way from the route handler — not just the final
<dl>or table row that renders it. - Is it gated in the route, or only in the renderer? A route-level omission (the field is never set on the object for that role) is the strong form — a renderer-level
if (isOwner)around the markup is a second, weaker layer that a later refactor can silently drop. Prefer the route-level gate; treat a renderer-only gate as a defect waiting to happen, not a substitute. - Is the field absent, or merely
null/unrendered?'hourly_cost' in payload === falseis a different, stronger claim thanpayload.hourly_cost === nullor "the HTML doesn't currently show it." #2095 shipped specifically to make the first claim true, not the second — see the row above for why. - Does the new surface already have a row in this table? If not, add one — naming the real enforcement point — in the same PR, per the Documentation Sync Protocol in
CLAUDE.md(repo root — not part of this published site, so deliberately not linked).
A note on Mutation 1 and "assert on the response body"
Most rows above are enforced by a route-level field omission feeding a presence-gated renderer (if (project.contract_value !== undefined)) — for those, a regression that reintroduces the field into a lower role's payload shows up directly in the rendered response body, and tests/data-visibility.test.js checks it there.
hourly_cost on /console/people/:userId is the one exception, and it is double-gated: both the route (which field it sets) and the renderer (isOwnerLevel(user.role), not field presence) key off the requester's role independently. A route-level regression there — reintroducing person.hourly_cost for a Manager — would not change a single byte of the rendered HTML, because the renderer's own gate is unconditional on role and never looks at whether the field is present. Catching that specific regression requires inspecting the object handed to the renderer, not just the HTML it produces — the same technique tests/console-person.test.js already uses for its own #2095 "leak test." tests/data-visibility.test.js uses it too, for this one field only, and says why inline.