access-control-hardeningbreakdown.mdscope.md behind this batch — technical
debt, no approval gate needed (Jamie's instruction). Notes quoted per stub.The permission templates declare own-scope for customers and experts on leads, proposals,
quotes and invoices, but several pages and actions never apply it: the leads board, proposal
list, quotes list and invoices list are tenant-wide to anyone who can reach them; the lead
workspace has no ownership check at all; proposal create/update has no server-side role gate;
a vendor can open any user's profile through the customers page; and the import wizard screen
renders for roles whose submit would be rejected. The root cause is the half-finished cutover:
the new permission engine runs in shadow mode while most actions still use legacy role lists.
This batch closes each exploitable leak with a targeted fix first (stubs 1–5, independently
shippable), then finishes the cutover so the two systems can't drift again (stub 6).
Feature-role-matrix entities: leads, proposals, projects, vendors; service-journey steps:
lead intake → bid pool → quote → delivery → invoicing. Lands in apps/web (pages, server
actions, route policies) and packages/services (list-query scoping parameters).
/vendor/customers/[id] to the vendor's own customers — depends-on: nonerequiredPermission; widen the lint rule to all action files — depends-on: workspace-ownership-guard, leads-board-and-import-scoping, proposal-access-and-actions, finance-lists-scoping, vendor-customer-profile-guard, permission-registry-extensionallowedRoles shim and shadow mode; centralise own-scope; rule on the deferred brd.view narrowing — depends-on: action-migration-lint-wideningStubs 1–5 are mutually independent (disjoint pages/actions) and can run as five parallel
sessions; stub 6 is additive (registry + defaults + matching route flips) and independent of
them too. Stubs 7–8 are the sequential tail of the old permission-engine-cutover stub,
re-cut per Jamie's ruling (2026-08-17: "split into three sequenced stubs before Define"):
7 generalises what 1–6 established and must read their shipped diffs; 8 deletes the legacy
path 7 made redundant. Foundation-first tie-break orders 1–5 by blast radius (workspace
first).
.icm/intake/v1-user-hierarchy/._done/action-migration-lint-widening.mdPart (b) of Jamie's three-way split of the old permission-engine-cutover stub. The
require-action-permission lint rule covers a named list of 10 migrated action files; the
other ~50 action files still gate on legacy allowedRoles lists, which the engine cannot see
and per-tenant template edits cannot affect. Wherever the two systems disagree, the legacy
list wins silently.
Migrate every remaining server action to requiredPermission (the registry now models every
surface after stub 6), reserving bare resolveActionContext()/authnOnly for genuinely
self-scoped reads, each with a stated reason. Extend the require-action-permission lint rule
from the named 10-file list to every action file, so an unmigrated action is a lint error, not
a silent legacy fallback.
allowedRoles alone; each declares requiredPermission or a
documented authnOnly.actions.ts/*-actions.ts file in apps/web, not a named
list./admin/settings/scope still reflects reality.allowedRoles shim or the shadow logger (stub 8) — during this stub the shim
keys stay in place for shadow comparison.Cut from permission-engine-cutover per Jamie's Q1 ruling (recorded 2026-08-17). Evidence
unchanged from the original stub: apps/web/lib/actions/index.ts (three context modes +
shadow compare), apps/web/eslint-rules/require-action-permission.mjs +
eslint.config.ts (migratedActionFiles). touches: apps/web (most action files),
apps/web/eslint-rules/, eslint.config.ts.
_done/finance-lists-scoping.mdThe quotes list shows every quote in the tenant to anyone holding invoice.view — all six
roles — because the pages never pass the viewer into a service that already supports scoping.
The invoices list scopes only experts: a customer at /finances/invoices sees everyone's
invoices, while the same data is correctly scoped on the /finances tab — two routes, two
behaviours. And /finances/approvals bounces admins to the home page with no message even
though its route policy admits them. Jamie's notes: "quotes need to be protected, right now
every quote in the tenant is visible to anyone", "the invoice list only scopes experts, a
customer can see everynes invoices", "the approvals queue bounces admins to the home page with
no message."
Pass the viewer's role and id into every quotes/invoices list read so customer, expert and vendor see only their own documents on every route that lists them; full-scope roles unchanged. Resolve the approvals disagreement one way: either admit admins to the approvals queue (policy stands, page guard goes) or exclude them in the policy so the link never renders — not a silent bounce.
/finances,
/finances/quotes and /finances/invoices.async-params-and-build-errors).invoice.approve)
says yes, the page says csm/sdm only. Jamie to rule when picking this up.Evidence: apps/web/components/finances/quotes-list/quotes-list.tsx:35 (accepts
userId/role, callers pass neither), apps/web/app/(app)/finances/page.tsx:81,
apps/web/app/(app)/finances/invoices/page.tsx:35 (passes role only when expert; the
/finances tab scopes customers correctly at finances/page.tsx:28-33),
approvals/page.tsx:16-18 (redirects non-csm/sdm). touches:
apps/web/app/(app)/finances/, apps/web/components/finances/.
Recorded by the intake-easy-features session; these rulings bind Define.
invoice.approve): admin, CSM and SDM all see /finances/approvals; no silent redirect._done/leads-board-and-import-scoping.md/service-leads calls getLeadValueSummary(tenantId) and findByStatuses(tenantId, …) with
no owner filter, so any customer or expert who navigates there sees the whole tenant's board —
budgets, customer names, assigned experts. The import wizard under the same policy renders its
screens (with the tenant's products, services and locations) to customers and experts whose
submit is then rejected. Jamie's notes: "the leads board does not filter on a per user basis,
all data is exposed" and "onboarding and import wizard is exposed to other user roles."
Apply the viewer's lead.view scope to the board's queries and summary tiles: customers see
their own leads, experts see their matched/bid-pool leads, full-scope roles are unchanged.
Give /service-leads/import its own route policy matching who can actually submit (admin,
CSM, SDM, vendor) instead of inheriting the board's. Verify /onboarding exposure at the same
time — its policy is admin+vendor today; confirm no other role can reach it and close any gap
found.
/service-leads/import is redirected by the route
policy, never rendered the wizard.Evidence: apps/web/app/(app)/service-leads/page.tsx:37-50 (unscoped reads); route policy
derives from lead.view, which customer and expert hold at own scope
(apps/web/lib/route-policies.ts); import submit-side rejection already exists
(apps/web/app/(app)/service-leads/import/actions.ts:85). The scoped-read parameters likely
belong on leadService so stub 6 can reuse them. touches: apps/web/app/(app)/service-leads/,
packages/services/src/db/services/leads/. Follow the web-route skill for the policy entry.
_done/permission-registry-extension.mdPart (a) of Jamie's three-way split of the old permission-engine-cutover stub. The registry
models only workspace features, so the admin/config surfaces, the taxonomy catalogue and
onboarding cannot move off legacy role lists — every one of their routes carries an explicit
allow-list with a "no registry home" reason, and their actions can never declare
requiredPermission.
Add the missing features to the registry — taxonomy (view/manage), tenantConfig
(view/manage), onboarding (run) — grant them in the platform role templates to the roles the
routes admit today (csm/sdm: taxonomy + tenantConfig.view; vendor: onboarding.run), realign
seeded tenants with a migration in lockstep, and flip every route policy whose derived
allow-list matches its old explicit list exactly. Behaviour parity everywhere.
ALL_FEATURES).requiredPermission (stub 7).allowedRoles shim or shadow mode (stub 8)./users and /activity — their pages render full-tenant PII unscoped, so
deriving from people.view/activity.view (held at own by customer/expert) would widen
access; they stay explicit until page-level scoping.Cut from permission-engine-cutover per Jamie's Q1 ruling (recorded 2026-08-17): "split into
three sequenced stubs before Define — (a) registry extension for unmodelled surfaces,
(b) action migration + lint widening, (c) shim/shadow removal." touches:
packages/services/src/permissions/{registry,defaults}.ts, packages/services/src/db/migrations/,
apps/web/lib/route-policies.ts.
_done/proposal-access-and-actions.mdThree related holes on one entity. (1) The proposal list and detail are open to everyone in
the tenant — an expert can read rival bids (price, title, bidder) on every lead, and customers
can too; ownership only gates the Edit button. (2) createProposal and updateProposal run
on a bare resolveActionContext() — no role, no permission — so any signed-in user can create
or edit a proposal, and the non-expert create page lists 100 tenant leads in a picker.
(3) The manager accept/reject actions gate on a legacy role list while the button that shows
them gates on proposal.respond — two sources of truth that already disagree. Jamie's notes:
"proposal list is entirely unprotected and open to everyone", "no role check for proposal
CRUD", "the proposal accept and reject flows have been slightly compromised."
Scope the list and detail to the viewer: experts see their own proposals, customers see
proposals submitted on their own leads (per the docs' ranked-proposals promise), managers see
all. Gate create/update/delete server-side as expert-only (plus the existing bid-pool match
check), and drop the all-leads picker for non-experts. Re-gate manager accept/reject on the
permission registry so lever and action agree — resolving the registry's current
proposal.respond holder list against the intended behaviour.
proposal.respond to sdm, expert and customer — should CSM hold it
too (the legacy action list says admin/csm/sdm)? Settle the holder list before re-gating.Evidence: apps/web/components/proposals/proposals-list/proposals-list.tsx:11-16 (unscoped
findAll), apps/web/app/(app)/proposals/page.tsx:25 (never passes expertId),
proposal-detail.tsx:102-114 (ownership gates Edit only),
apps/web/app/(app)/proposals/create/actions.ts:96,147 (bare context),
create/page.tsx:77 (all-leads picker), proposal-detail/actions.ts:257,293 (legacy role
list vs proposal.respond). touches: apps/web/app/(app)/proposals/,
apps/web/components/proposals/. Follow the server-action skill's three-step pipeline.
Recorded by the intake-easy-features session; these rulings bind Define.
_done/shim-shadow-removal.mdPart (c) of Jamie's three-way split of the old permission-engine-cutover stub. Once every
action enforces on the resolver (stub 7), the legacy allowedRoles option and the
shadow-mode logger are dead weight: two code paths that can only drift from the enforced one.
Delete the allowedRoles option from resolveActionContext and every remaining reference,
remove logShadowPermissionCheck and the permissions/shadow.ts module, and make own-scope
enforcement a resolver/service concern rather than per-page code where stubs 1–5 left tactical
guards that the central path now subsumes. Revisit the brd.view full-scope template defaults
for expert/customer — the narrowing stub 1 deferred here on Jamie's ruling.
resolveActionContext accepts only requiredPermission/authnOnly (+ onboarding
gate); the shim and the shadow logger are deleted.brd.view defaults for expert/customer ruled on and applied (with a template migration
if narrowed)./admin/settings/scope still reflects reality after the removal..icm/intake/v1-user-hierarchy/).Cut from permission-engine-cutover per Jamie's Q1 ruling (recorded 2026-08-17). Evidence:
apps/web/lib/actions/index.ts, packages/services/src/permissions/shadow.ts,
defaults.ts (brd.view full for expert/customer — deferred narrowing from
workspace-ownership-guard). touches: apps/web/lib/actions/,
packages/services/src/permissions/.
_done/vendor-customer-profile-guard.md/vendor/customers/[id] mounts the generic user profile with the raw route id and no
assertion that the target is a customer — or the vendor's customer. A vendor can read any
tenant user's PII (email, phone, address, last login), including admins and experts, and the
profile edit form renders even though its action would reject. The /users directory
deliberately excludes vendors for exactly this reason; this route goes around it. Jamie's
note: "a vendor can open any user's profile through their customers page. fix this."
Guard the page: the id must resolve to a customer user, and one with a relationship to the requesting vendor (leads/engagements with that vendor), else 404. Render a vendor-appropriate read-only view — a customer relationship card, not the internal staff profile with its edit affordances.
/vendor/customers/ gets a 404./users access for staff roles is unchanged.Evidence: /vendor/customers/[id] renders the generic UserProfile with no is-a-customer
check; apps/web/lib/route-policies.ts:68 documents why vendors are excluded from /users
(REASON_FULL_TENANT_PII). Define what "the vendor's customer" means precisely (customer on
any lead where vendor = this vendor is the natural read). touches:
apps/web/app/(app)/vendor/customers/.
Recorded by the intake-easy-features session; these rulings bind Define.
_done/workspace-ownership-guard.mdOpening /workspace/[id] is not ownership-checked: the page loads any lead in the tenant by
id and only 404s when it doesn't exist. lead.view is own-scoped for customer and expert,
but that scope is applied only to the proposals satellite — so any customer or expert can open
any other customer's project and read its BRD, quote, milestones, invoices, blockers and
activity. Jamie's note: "workspace url is not ownership checked, we need to ensure workspaces
are protected." This is the highest-impact leak in the audit.
Enforce the viewer's declared lead.view scope at workspace load: a customer or expert whose
scope is own gets notFound() (not a redirect that confirms existence) on any lead they are
not a party to; full-scope roles (admin, CSM, SDM) are unchanged. The same check must cover
every workspace satellite read and every workspace server action, not just the page shell —
one shared guard, not per-satellite copies.
brd.view reads through the same guard, closing the compounding leak (the BRD section
was visible on any lead a persona could open).Evidence: apps/web/app/(app)/workspace/[id]/page.tsx + apps/web/lib/queries/workspace.ts:130
(loads by leadService.findById(tenantId, leadId), no party check; proposals-only scoping at
workspace.ts:281-296). The aggregate cache key already includes viewerId, so a viewer-aware
guard fits the existing cache shape. PLATFORM_ROLE_TEMPLATE_DEFAULTS grants brd.view at
full to expert and customer (packages/services/src/permissions/defaults.ts:110,146) — Define
should decide whether to narrow that default here or lean on the lead guard alone (narrowing
belongs to stub 6 if deferred). touches: apps/web/app/(app)/workspace/,
apps/web/lib/queries/workspace.ts, apps/web/components/workspace/actions.ts. Follow the
web-route and server-action skills' patterns.
Recorded by the intake-easy-features session; these rulings bind Define.