Skip to content

Permissions — Canonical Matrix

This file is the single source of truth for roles, permissions, and who-can-do-what. A drift test (src/test/permissions.matrix.test.ts) parses this file and fails the build if the code uses a permission that isn't declared here, or if this file declares a permission no code references. If you add a page/section/action, update this file — the test will fail otherwise.

Last audited: 2026-09-18 (post-upgrade documentation pass; role and permission facts re-verified against the migrations and the drift tests)


1. How to use this document

  • Adding a new page, section, or action? Add a row to the relevant section in §6. If the action introduces a new permission name, add it to §4 (catalog) and §5 (role grants).
  • Changing who can do what? Update §5. Update the seed migration (supabase/migrations/*seed*permissions*.sql). Run npm test to confirm the drift test still passes.
  • Removing a permission? Remove every reference from code first (grep permission="x.y", requirePermission('x.y')), then remove from §4/§5, then add a DROP migration.

2. Architecture

Permission enforcement happens at three layers:

  1. Route-level (client) — <ProtectedRoute requiredPermissions={[...]}> in src/App.tsx. Gates whole pages.
  2. UI-level (client) — <PermissionGate permission="..."> wrapping individual buttons/actions in component trees.
  3. API-level (server) — requirePermission('...') in src/lib/api.ts handler functions. The authoritative check. Also backed by Postgres RLS policies on sensitive tables (Booking, Customer, Agent, Supplier, Payment, JournalEntry, JournalLine, FinanceConfig) via auth_user_has_permission(perm_name).

The SQL function order of precedence (post 20260416020000): 1. explicit user-level deny (UserPermission.allowed=false) → denied 2. explicit user-level allow (UserPermission.allowed=true) → granted 3. role grant (RolePermission) → granted 4. otherwise → denied

No role-name bypasses. SUPER_ADMIN is the only role that holds every permission, and it holds them because every permission row is granted to it explicitly — not by a hardcoded short-circuit. A trigger grants each newly inserted permission to it as well. Every other role, CEO, GM, IT_ADMIN and ADMIN_HR included, holds only its bundle (§5), and apply_role_bundles() deletes grants outside that bundle. Revoking a specific row in the PermissionsMatrix UI actually restricts the holder. (ACC-011 / ACC-012, decided 2026-09-17 — see docs/rules/decisions-log.md.)


3. Role catalog

23 roles total — 1 super admin, 4 admin-tier, 15 functional staff, 1 field role (tour leader), 2 portal-scoped (partner/customer).

Role Tier Purpose Auto-grant?
SUPER_ADMIN super-admin The owner's administrator. The only role with every permission, including the break-glass journal overrides and destructive/system rights. Everyone else gets rights the super admin grants. Seeded every permission, and every newly inserted permission, by 20260919100200_finance_role_bundles.sql
CEO leadership Chief executive; ultimate business authority Leadership bundle: every *.view / *.export, business approvals, finance.supplier_transactions.correct (20260919100200)
GM leadership General Manager; operational head Leadership bundle (20260919100200)
IT_ADMIN admin System administrator; tech owner — system settings only Users, MFA reset, integrations, audit, currencies/cities, config (20260919100200)
ADMIN_HR admin HR/admin operations — no finance, approvals, invoices or permission editing Seeded most permissions (20260115000000 …) minus the F1 revokes (20260919100200)
SALES_MANAGER functional Sales team lead Sales bundle
SALES_EXEC functional Sales representative Sales-minimal bundle
B2B_MANAGER functional B2B/partner account manager Partner + sales bundle
B2B_EXEC functional B2B sales rep B2B-minimal bundle
OPS_MANAGER functional Operations lead (groups, inventory) Operations bundle
OPS_EXEC functional Operations staff Operations-minimal bundle
FINANCE_MANAGER functional Finance lead Finance + approvals bundle (no break-glass, ledger rebuild, year/period close)
ACCOUNTANT functional Bookkeeper — prepares, verifies other people's receipts, reconciles Finance bundle without approvals, year close, configuration or numbering
CHARTERED_ACCOUNTANT functional External CA — reads finance, reports and the audit trail; prepares journals and supplier-transaction corrections; no cash handling CA bundle (20260919100200)
CASHIER functional Records receipts only finance.view, finance.payments.record, plus the general read bundle (bookings.view, customers.view, … ) — read-only everywhere else
TICKET_MANAGER functional Ticketing lead — owns the airline blocks (AIR §30) Tickets + reports
TICKET_EXEC functional Ticketing executive — enters passengers, allocates seats, updates PNRs, edits blocks, sells seats on, records the airline's payment (AIR §30, FIN-044) Ticketing-minimal bundle (20260929150000); no approvals
VISA_OFFICER functional Visa processing Visa bundle
AUDITOR functional Read-only oversight bookings.view, reports.export
TOUR_LEADER field The group leader / mutawwif who travels with a departure; sees only the groups assigned to them (TravelGroup.leadUserId), on a phone (FLD-001) field.view, field.checkin only (20260928170100)
AGENT portal B2B partner user (external) Portal-only; no staff permissions
CUSTOMER portal End customer (external) Portal-only; no staff permissions

Custom roles can be created via the Admin → Permissions UI. They start with zero permissions and must be granted explicitly.


4. Permission catalog

Permission name format: <module>.<action> or <module>.<sub-resource>.<action>. Every permission here MUST be inserted into the Permission table via a seed migration, and MUST be referenced in code (otherwise the drift test fails).

4.1 Core staff modules

Module Permission Meaning Enforced at
agents (B2B partners) agents.view List/read B2B partners Route /agents, Sidebar
agents.create Create new partner <PermissionGate> on "Add Business Partner"
agents.delete Permanently delete a partner (and their ledger) <PermissionGate> on row Delete button, API DELETE — SUPER_ADMIN only since 20260919100200 listed it in fin_super_admin_only_permissions(); the earlier "IT_ADMIN only" of 20260430140000 no longer holds
communications communications.send Send customer WhatsApp / email messages (single, bulk, scheduled); record contact consent / opt-out; read the outbound queue, which carries every recipient's contact details. Server-side in the whatsapp-send edge function and /operations/communications requirePermission + <PermissionGate> on send buttons; whatsapp-send guard; RLS on CommunicationQueue per command since 20260926230000 (no DELETE for anyone)
approvals approvals.view See the approvals queue Route /approvals, Sidebar
approvals.approve Approve/reject/send-back bookings <PermissionGate> in Approvals.tsx + server-side
bookings bookings.view List/read bookings Route /sales/bookings, /sales/bookings/:id
bookings.create Create new booking (wizard, import) Route /sales/bookings/new, API POST
bookings.edit Modify booking fields, add passengers <PermissionGate>, API PATCH
bookings.delete Soft/hard-delete booking <PermissionGate>, API DELETE
bookings.cancel Submit a cancellation request for a booking or passenger (executes immediately for users who also hold bookings.cancel.approve) <PermissionGate>, API POST
bookings.cancel.approve Approve or reject a pending cancellation request (the refund override is set here). Paying the refund out needs finance.payments.refund since F1 <PermissionGate>, API POST /cancel/approve, /cancel/reject
booking.transfer Transfer a passenger to a different group/booking. Granted to CEO, GM, IT_ADMIN, ADMIN_HR, SALES_MANAGER, SALES_EXEC, B2B_MANAGER, B2B_EXEC, OPS_MANAGER. <PermissionGate>, API POST
customers customers.view List/read customers Route /sales/customers, /people
customers.create Create new customer <PermissionGate>, wizard customer creation
customers.edit Edit customer profile, documents <PermissionGate>, API PATCH
customers.delete Delete customer record <PermissionGate>, API DELETE
finance finance.view See Finance module / reports / vouchers Route /finance
finance.create Post journals and vouchers (payment, contra, notes, settlement, supplier refund and business partner receipts) <PermissionGate> on the journal composer and Finance → Vouchers
finance.edit Edit finance config, reconcile RLS on FinanceConfig, Payment/Journal updates
finance.payments.record Record a receipt against a booking or invoice (pending until verified), allocate an agent receipt <PermissionGate> on "Add Payment" (booking page, Bookings list), Finance → Transactions "Record Payment" / "Pay", Finance → Vouchers receipt against a booking; API POST /finance/payments, /finance/payments/batch, /finance/vouchers/agent-receipt-allocate
accounts accounts.delete Hard-delete a Chart-of-Accounts row (only when it has no transactions, children, or linked financial accounts) API DELETE /finance/accounts/:id, RLS on Account
groups groups.view List/read travel groups Route /groups
groups.create Create new group <PermissionGate> on "Create Group"
groups.edit Edit group, reassign passengers, link/unlink flights, misc expenses <PermissionGate> + API PATCH/POST
groups.delete Soft-delete group API DELETE /groups/:id
group_pricing group_pricing.edit Edit the rate sheet on the group's Pricing tab (4 line items + tax lines) API PATCH /groups/:id/pricing, <PermissionGate> on Pricing tab save
cancellation_policies cancellation_policies.manage Create, edit and delete cancellation policies — bands, flat charges, percentages and kept items (PRC-020 / PRC-023) <PermissionGate> on /admin/cancellation-policies, API POST/PATCH/DELETE /cancellation-policies, RLS on CancellationPolicy / CancellationPolicySlab / CancellationPolicyComponent, DB save_cancellation_policy
group_invoices group_invoices.view See group invoices tab + individual invoices Route (Invoices tab on group), API GET
group_invoices.create Create a draft invoice from a group payer <PermissionGate> on Create draft invoice (also Internal invoice and Supplementary), API POST
group_invoices.edit Edit a DRAFT invoice (line items, taxes, due date) <PermissionGate> + API PATCH (server rejects on ISSUED)
group_invoices.issue Transition DRAFT → ISSUED (numbers and locks it; posts nothing); and Post to Finance — the revenue + tax voucher, pending a finance approver <PermissionGate> + API POST /group-invoices/:id/issue and POST /group-invoices/:id/post
group_invoices.cancel Cancel an ISSUED invoice (posts reversing credit-note journal) <PermissionGate> + API POST /group-invoices/:id/cancel
hotels hotels.view List/read hotels Route /hotels, Sidebar
hotels.create Create hotel/facility <PermissionGate>
hotels.edit Update hotel fields, contracts <PermissionGate>
hotels.delete Remove hotel <PermissionGate>
hotels.export Export/print hotel reports <PermissionGate> on export buttons
food food.view List/read food inventory Route /food, Sidebar
food.create Create food item, import CSV <PermissionGate>
food.edit Update food item, assign meals to a group <PermissionGate>
food.delete Remove food item <PermissionGate>
food.export Export/print food reports <PermissionGate> on export buttons
incidents (duty of care) incidents.view Read incidents, checklists, communication log, documents and the health details on them (INC-002, C360-003) Route /operations/incidents, requirePermission; RLS on Incident, IncidentTraveller, IncidentChecklistItem, IncidentUpdate, IncidentDocument, TravellerStateEvent; storage bucket incident-documents
incidents.manage Report an incident, assign the owner and family liaison, work the checklist, log calls and updates, upload documents, record costs, set hospitalised / missing / found / discharged <PermissionGate>; DB open_incident, assign_incident_owner, update_incident_details, update_checklist_item, add_incident_checklist_item, add_incident_update, add_incident_document, set_traveller_state
incidents.close Close an incident (every step done or not applicable) and reopen a closed one; also the management list recorded as notified for a Critical incident <PermissionGate>; DB change_incident_status
incidents.confirm_death Confirm a traveller deceased, or correct that record — management only, with the incident number typed back (INC-012) requirePermission; DB set_traveller_state
inventory inventory.view Access inventory module (airline blocks, FIT, ground, B2B) Route /inventory/*, /suppliers
inventory.create Create airline block / FIT / ground transfer / B2B offer <PermissionGate>
inventory.edit Edit inventory record; also archive/restore an airline block or FIT inventory (AIR §26) <PermissionGate>, inline forms; DB archive_airline_block, restore_airline_block, archive_fit_inventory, restore_fit_inventory
inventory.counters.repair Repair a drifted inventory counter to the value derived from the allocation records. Reading the drift report needs only inventory.view; applying a correction needs this, a written reason, and every counter changed is recorded in InventoryDriftRepair (AUD-010, INV-013) requirePermission on POST /inventory/drift/repair; DB inventory_repair_drift
inventory.holds.manage Create, extend, convert and release a hold on airline-block, FIT, hotel-room or ground capacity (AIR §14, INV-011) <PermissionGate>; DB create_inventory_hold, extend_inventory_hold, convert_inventory_hold, release_inventory_hold, expire_inventory_holds
inventory.release.request Request a seat release, or withdraw your own request (AIR §16) <PermissionGate>; DB request_seat_release, cancel_seat_release
inventory.release.approve Approve or reject a seat release up to the seat limit in FinanceConfig.seatReleaseApprovalSeatLimit (default 10, ACC-030) — never your own request <PermissionGate>; DB approve_seat_release, reject_seat_release
inventory.release.approve_large Approve a seat release above that limit (ACC-030: CEO/GM) DB approve_seat_release (the seat count decides which permission applies)
inventory.release.approve_own Approve or reject your own seat release request (owner, 26 Sep 2026: TICKET_MANAGER). The decision is marked selfDecided on the release and written to the audit trail; the seat limit still applies (ACC-030) DB approve_seat_release, reject_seat_release (20260930000000)
inventory.release.file Record an approved release's airline filing (airline reference, actual penalty and refund). Opens the airline cancellation filing in pending_refund; finance settles the refund (CXL-030). SUPER_ADMIN, TICKET_MANAGER, TICKET_EXEC and every finance.create role DB complete_seat_release (20260930030000); POST /inventory/releases/:id/complete
inventory.block_payments.record Record a payment to an airline for a quota block or FIT purchase — posted pending finance approval (FIN-044, FIN-032). The database applies the overpayment guard (INV-025), converts a foreign contract at its rate (FIN-034) and refuses a TDS supplier unless the caller also holds finance.tds.deduct (FIN-021) AirlineBlocks.tsx / FITInventory.tsx Post Payment (hasPermission); POST /inventory/quota-blocks/:id/finance-events and /inventory/fit/:id/finance-events (supplier_payment) and POST /suppliers/:id/transactions (a credit against quota_block / fit) send a holder without the route's own gate through DB record_airline_block_payment; GET /inventory/paid-from-accounts (DB airline_block_paid_from_accounts); the native app's Record payment. Money back from the airline: the web Record refund from the airline (AirlineBlocks.tsx, <PermissionGate>) → POST /inventory/quota-blocks/:id/airline-refund → DB record_airline_block_refund; the native app's Record refund
inventory.b2b_cancellations.file File the cancellation of a third-party seat sale — seats, the refund to the buyer, the buyer's charge, optionally the supplier's side — for finance to decide (INV-014, FIN-032). Filing only: the decision is finance.cancellations.approve_b2b (decide_b2b_cancellation), never the filer's AirlineBlocks.tsx Cancel Sale / File Cancellation (hasPermission, either this or finance.create); POST /finance/b2b-cancellations sends a holder without finance.create through DB file_b2b_cancellation; the native app's Cancel this sale
inventory.writeoff.approve Write seats off that were bought and never sold, so their cost leaves Stock-in-Hand and becomes a real expense (FIN-037). Needs a written reason; the approver is recorded and the row is immutable requirePermission on POST /inventory/quota-blocks/:id/write-off-unsold; RLS on UnsoldSeatWriteOff
leads leads.view List/read leads Route /sales/leads
leads.edit Update lead status, edit fields <PermissionGate> in detail dialog
partners partners.create Create partner (alt to agents) Legacy; kept for RLS policies
partners.edit Edit partner Legacy alias
partners.approve Approve or reject a new business partner, or make active one never approved (PTR-095) <PermissionGate>, POST /agents/:id/status, partner_set_status, trigger Agent_approval_guard
partners.delete Delete partner Legacy alias
partners.credit.override Take a partner over its credit limit (PTR-030) — only with a written reason of at least 3 characters, sent with the booking write; the database checks it, records it in the audit trail (partner_credit_override) and does not store it on the booking. Held by FINANCE_MANAGER, CEO, GM, SUPER_ADMIN (20261006120000) DB trigger "Booking_credit_limit" (bkg_credit_limit_guard) on every booking write; booking wizard (WizardShell.tsx, hasPermission) offers Override and save when the save is refused
quotations quotations.view List/read quotations Route /sales/quotations
quotations.create Create new quotation; also used for edit/send/delete until split <PermissionGate>
reports reports.view Access analytics / reports pages Route /reports
reports.export Download/print any report <PermissionGate> on export buttons
requests requests.view See service requests queue Route /requests
requests.create Create a service request (ops-facing; customer/agent portals are self-service) requirePermission in API
requests.edit Update or add notes to a service request requirePermission in API
suppliers suppliers.create Add supplier <PermissionGate>
suppliers.edit Edit supplier, record transactions <PermissionGate>
suppliers.approve Approve or reject a new supplier (PTY-009) <PermissionGate>, POST /suppliers/:id/decision, supplier_decide
suppliers.delete Remove supplier <PermissionGate>
tickets tickets.view List/read tickets Route /tickets
tickets.edit Update passenger names (before the name deadline), request a ticketing exception, upload docs <PermissionGate>; DB update_ticket_names, request_ticket_exception
tickets.approve Issue a ticket; approve/reject an exception requested by someone else (maker-checker) <PermissionGate>; DB issue_ticket, decide_ticket_exception
visa visa.view List/read visa cases Route /visa
visa.create Create new visa case <PermissionGate>
visa.edit Update case status (VISA-003 transitions only), upload / soft-delete docs <PermissionGate>; DB change_visa_status, add_visa_document, delete_visa_document; drive-upload kind visa_doc
visa.groups.view List/read visa groups + drill-in Route /visa/groups/:id
visa.groups.edit Edit visa group code, status override, close <PermissionGate>
visa.intake.view List/read public visa intake queue Route /sales/visa
visa.intake.convert Convert intake → VisaCase (carries all fields + attachments, matches customer by passport); change intake status / reject <PermissionGate>; DB convert_visa_intake, update_visa_intake

| work (work inbox) | work.view | See the work inbox and your own items (WRK-001) | Route /work, Sidebar, requirePermission, RLS on every Work* table | | | work.view.queue | See the queues you lead, and another person's list (PERF-007) | work_inbox_page scope check; tab visibility | | | work.view.all | See everybody's work | work_inbox_page scope check; tab visibility | | perf (performance) | perf.view.own | See your own performance numbers (PERF-007) | Route /performance, Sidebar, requirePermission; DB perf_require_view | | | perf.view.all | See anyone's performance numbers; each look is audited (PERF-007, PERF-011) | person picker; DB perf_require_view, perf_people | | | work.create | Log a job or type a task (WRK-002) | <PermissionGate> on "Log a job"; DB work_item_create | | | work.claim | Claim a pool item, park it, answer it, put it back (WRK-006) | <PermissionGate>; DB work_item_claim / _release / _wait / _resume / _record_response | | | work.close | Finish an item | <PermissionGate>; DB work_item_close | | | work.assign | Hand work to a named person, see suggestions, cancel an item (WRK-007) | <PermissionGate>; DB work_item_assign, suggest_work_assignee, work_item_close(status=cancelled) | | | work.reopen | Reopen a closed item | <PermissionGate>; DB work_item_reopen | | | work.queues.manage | Name the members and leads of each work queue, set their weekly capacity points, take them off (WRK-013) | <PermissionGate> in Work → Queues; requirePermission; DB work_queue_member_save / work_queue_member_remove |

4.2 Admin module

Permission Meaning Enforced at
admin.view Access /admin and /people/employees Route guards

Granted to every role that holds another admin.* right: IT_ADMIN, ADMIN_HR, CHARTERED_ACCOUNTANT (it needs /admin to reach the audit log), OPS_MANAGER, CEO, GM, AUDITOR, SUPER_ADMIN.

Legacy admin.edit kept for backwards-compat with existing RLS. New code should use the specific admin.* permissions defined in §4.4 below.

4.3 Cross-cutting (dashboard, chat)

Permission Meaning Enforced at
dashboard.view Show Dashboard nav item and KPIs Sidebar nav filter
chat.view Access internal chat Route /internal/chat

4.4 Granular sectional permissions (phase 1)

The coarse finance.view / admin.view gate the ROUTE. These sectional permissions gate individual sections inside those pages so an admin can e.g. hide Profit & Loss from an Accountant while still letting them see Trial Balance. A user needs BOTH the parent route permission AND the sectional permission.

Finance reports — each has independent view + export:

Permission Meaning
finance.reports.trial_balance.view Trial Balance report
finance.reports.trial_balance.export Export Trial Balance
finance.reports.day_book.view Day Book report
finance.reports.day_book.export Export Day Book
finance.reports.balance_sheet.view Balance Sheet
finance.reports.balance_sheet.export Export Balance Sheet
finance.reports.profit_loss.view Profit & Loss
finance.reports.profit_loss.export Export P&L
finance.reports.group_profit_loss.view Group Profitability
finance.reports.group_profit_loss.export Export Group P&L
finance.reports.aging.view Aging Report
finance.reports.aging.export Export Aging
finance.reports.receivables_payables.view Receivables & Payables summary
finance.reports.receivables_payables.export Export R&P
finance.reports.gst_summary.view GST Summary
finance.reports.gst_summary.export Export GST
finance.reports.stock_status.view Stock Status (inventory valuation)
finance.reports.stock_status.export Export Stock Status
finance.reports.group_status.view Group Status
finance.reports.group_status.export Export Group Status
finance.reports.settlements.view Settlement Report
finance.reports.settlements.export Export Settlements

Finance — TDS (Tax Deducted at Source):

Permission Meaning
finance.tds.view View TDS rate master and deduction ledger
finance.tds.deduct Apply TDS on a supplier payment
finance.tds.export Export the quarterly 26Q CSV

Finance workflow — split from the coarse finance.create / approvals.approve:

Permission Meaning
finance.payments.verify Verify a pending payment (never one you recorded — DB verify_payment)
finance.payments.reject Reject a pending payment. finance.payments.verify also allows rejecting (route and DB verify_payment), so Reject shows for either
finance.payments.refund Request a refund (capped at paid minus prior refunds — DB refund_payment / request_booking_refund)
finance.refunds.approve Approve or reject a refund request someone else filed; approval posts the refund voucher (DB approve_refund)
finance.journals.approve Approve (post) a pending voucher created by someone else (DB approve_journal_entries; direct UPDATE to approved is refused without it)
finance.journals.approve_own Override: bypass maker-checker to approve/reject a journal YOU created. Super-admin only; every use is written to the audit trail
finance.journals.reject Reject a pending voucher
finance.journals.bulk_approve Bulk approve journals (also needs finance.journals.approve)
finance.journals.reverse Post a reversal voucher. It is approved only by someone who made neither the original nor the reversal (ACC-020; DB approve_journal_entries since 20261006120000)
finance.journals.reverse_own Override: bypass maker-checker to reverse a journal YOU created from the website's Reverse action (a screen-level check). Super-admin only
finance.bookings.approve_finance Finance-tier booking approval
finance.bookings.reject_finance Finance-tier booking rejection
finance.cancellations.approve_airline Approve airline cancellation refund
finance.cancellations.approve_b2b Approve / reject a B2B buyer cancellation filed by someone else (DB decide_b2b_cancellation)
finance.allocations.agent_receipt Allocate on-account agent receipt
finance.allocations.supplier_advance Allocate supplier advance
finance.stock_adjustment.post Post opening/closing stock adjustment
finance.ledger.rebuild Rebuild missing postings (DB rebuild_payment_postings), airline-block postings and manual ledger entries
finance.ledger.reset Retired 2026-09-18: the "Clear All Ledger Data" wipe was removed (FIN-031). The route answers 410; posted vouchers can only be changed with service-role break-glass
finance.config.edit Edit finance config: document prefixes (the numbering counters themselves are only moved by the database) and GL defaults
finance.supplier_transactions.correct Correct or remove a supplier transaction whose voucher has been reversed (DB correct_supplier_transaction, reason required, audited). Held by ACCOUNTANT, CHARTERED_ACCOUNTANT, CEO, GM, SUPER_ADMIN
finance.approvals.high_value Approve money above the tier-1 limit in ApprovalLimit (ACC-030 — customer refunds above ₹50,000, manual journals above ₹25,000 today). Held by CEO, GM, SUPER_ADMIN
finance.rates.manage Add, change or delete an exchange rate, and refresh rates from the live feed (FIN-046). The only way to write exchange_rates: DB set_exchange_rate / delete_exchange_rate. Held by FINANCE_MANAGER, ACCOUNTANT, CEO, GM, SUPER_ADMIN (20261006100000)
finance.approvals.high_value Approve money above the tier-1 limit in ApprovalLimit (ACC-030 — customer refunds above ₹50,000; above ₹25,000, every voucher a person writes: journals, contras, on-account receipts, reversals on custom lines). Held by CEO, GM, SUPER_ADMIN

Finance period/year management:

Permission Meaning
finance.periods.create Create accounting period
finance.periods.lock Lock accounting period
finance.periods.unlock Unlock period
finance.periods.close Permanently close period (exec-level)
finance.years.create Create financial year
finance.years.close Close financial year (exec-level) — DB close_financial_year closes every period in the year and refuses while vouchers dated in it are pending

Admin — split from the monolithic admin.view:

Permission Meaning
admin.dashboard.view Admin landing dashboard
admin.audit.view Audit log access
admin.audit.export Export audit log
admin.currency.view View currencies
admin.currency.create Create currency
admin.currency.edit Edit currency
admin.currency.delete Delete currency
admin.cities.view View service cities
admin.cities.create Create city
admin.cities.edit Edit city
admin.cities.delete Delete city
admin.locations.view India locations hierarchy
admin.integrations.view View email/WhatsApp/SMS config — non-secret settings and "configured" flags only (DB integration_settings_public)
admin.integrations.edit Edit integrations; set or clear secrets write-only (DB set_integration_secret)
admin.integrations.test Send test messages (edge function integration-test)
admin.config.edit System config (branding, numbering)
admin.permissions.view View permissions matrix
admin.permissions.edit Edit permissions matrix (privilege escalation — super-admin only)
admin.users.view View staff users
admin.users.create Create staff user
admin.users.edit Edit a staff login — profile, username, email, pause / resume, document review. Not its roles since 20261003165000: a role changes only by a role request (§4.7, ACC-077)
admin.users.delete Delete staff user (super-admin only)
admin.users.reset_password Reset staff password
admin.mfa.reset Clear another user's authenticator factor + backup codes (super-admin only)
admin.roles.view View custom roles
admin.roles.create Create custom role
admin.roles.edit Edit custom role
admin.roles.delete Delete custom role (super-admin only)

Agents — split from coarse agents.*:

Permission Meaning
agents.deactivate Soft-disable partner
agents.credit_limit.set Set a partner's credit limit, on a new partner or an existing one — finance and leadership only (PTR-030; checked in the database by Agent_terms_guard and partner_staff_create, 20261008230000)
agents.commission.set Set a partner's commission rate, on a new partner or an existing one — finance and leadership only (PTR-030; same checks)
agents.access_key.manage Create/reset partner portal login
agents.ledger.view Partner ledger access
agents.ledger.export Export partner ledger
agents.allocate_receipt Allocate on-account receipt to bookings

4.4a Field (tour-leader app)

Module Permission Meaning Enforced at
field field.view The groups I lead: manifest, rooming, itinerary, check-ins — no money. Route /field, fld_my_groups(), fld_group_manifest(), fld_group_checkins() (each also admits groups.view) <ProtectedRoute> + SECURITY DEFINER functions
field.checkin Record a check-in for a group I lead (fld_record_checkin(); groups.edit may record one for any group) <ProtectedRoute> on /field/groups/:id/check-in, requirePermission, function

4.4b Programme (the traveller's guide, native app)

Module Permission Meaning Enforced at
programme programme.manage Write the step-by-step trip guide every traveller reads in the app (TRV-002): trv_upsert_guide_step(), trv_delete_guide_step(), and reading inactive steps. Since 20261008200000 also its parts and translations — trv_upsert_guide_part(), trv_delete_guide_part(), trv_save_guide_translation() — and approving plain text in another language (trv_guide_set_status(), TRV-018); reading the editor (trv_guide_editor()) and the preview (trv_guide_preview()). Native app only — there is no web screen for it, so the drift test lists it under ALLOWED_DOC_ONLY SECURITY DEFINER functions + RLS on TripGuideStep; the app's guide editor (apps/mobile/src/lib/permissions.ts) hides the screen
guide guide.religious.approve Approve religious text in the guide — a verse or dua (its Arabic, transliteration and meaning in every language) and anything marked Sunni or Shia — so that it replaces the approved text travellers read (trv_guide_set_status(), TRV-019). A second person: a holder may not approve text they wrote or last changed (maker-checker). Opens the guide editor and preview read-only for a holder without programme.manage. The office gives it to named people it trusts for religious content. Native app only, so the drift test lists it under ALLOWED_DOC_ONLY trv_guide_set_status() (refuses religious text without it, and refuses its draftedBy); the app's More → Guide for travellers row and the Approve buttons hide

The programme of a departure (activities, notices) needs no new permission: groups.edit writes it, the group's tour leader (field.checkin + TravelGroup.leadUserId) writes their own, and groups.view, the leader, the group's travellers and its partner read it (TRV-003, TRV-004). Programme templates (INV-009) need none either: groups.view reads them and groups.edit saves, applies, renames and deletes them. programme.manage is the traveller guide's permission, not the templates'.

4.5 Portal-scoped permissions (NON-STAFF)

Portal users (role AGENT or CUSTOMER) get zero staff permissions. Since 20260918100000_data_isolation_portals.sql (Wave 1A, ACC-001/002/010) their access is enforced in the database, not in the browser API layer (src/lib/api.ts):

  • Reads — row-level security ownership policies. A customer reads only rows linked to their own Customer record (Customer.userId = auth.uid(), never matched by email): their bookings (as customer or payer), passengers, payments, invoices, visa cases/documents, tickets, quotations, requests, group invoices. A partner reads only their agency (Agent.userId = auth.uid()): their Agent row, customers and bookings with their agentId, seat sales, partner invoices and documents, offers they bought or reserved, and their statement ledger entries. Helpers: auth_is_staff(), auth_customer_ids(), auth_agent_id(), auth_agent_status(); auth_reads_beyond_field() (staff with a permission outside field.*) guards the staff-wide reads a tour leader has no business on — User, UserRole, UserPermission, the ledger tables, chat, the communications log and WhatsApp (20260929170000).
  • Writes — portal users have no direct table writes. Every portal action is a SECURITY DEFINER function that takes the actor from the session and checks ownership, status, amounts and prices (list below). Database refusals map to HTTP 403 (42501), 409 (23514) or 404.
  • Staff read business tables through *.view permission sets (e.g. Booking needs any of bookings.view, groups.view, finance.view, visa.view, tickets.view, …); is_staff_user() no longer includes AGENT or deactivated users.

Routes remain gated by allowedRoles: - /customer/** → allowedRoles={['customer']} - /partner/** → allowedRoles={['agent']}

Portal functions (execute: signed-in users; each checks its own actor):

Function Who Checks
customer_submit_payment Customer Own booking, not cancelled/rejected; amount > 0 and ≤ outstanding (verified + pending counted); status always pending (FIN-032); receipt is a private storage key; double submit returns the first payment
customer_respond_quotation Customer Own quotation; status sent/pending; not expired; note appended
customer_create_request / customer_respond_request Customer Own booking / group is open or theirs / own request not closed; attachments are storage keys
customer_update_profile Customer Allow-listed fields; name, DOB and passport frozen once any visa case is past NOT_STARTED
customer_agent_contact Customer Their partner's name, company, phone, email only
partner_register New login PAN/GSTIN format, agreement accepted, AGENT role via self_assign_portal_role, Agent.status = 'pending', documents without public URLs (PTR-001)
partner_update_profile Partner Name and contact person only
partner_create_booking Active partner Price per passenger from the group rate sheet by category (or published B2B seat price); client prices ignored; credit limit; capacity; PENDING_OPS, on_account, createdBy = caller (PTR-020/021/030)
partner_add_passengers Active partner Own booking in DRAFT/PENDING_OPS/NEEDS_CORRECTION; rate sheet price; credit; capacity
partner_reserve_beds Active partner Atomic decrement of bedsAvailable; offer active and unreserved or theirs (PTR-040)
partner_sell_seats Active partner Offer bought by them; one seat counter (B2BFlightOffer.seatsAvailable) under row lock (PTR-041)
partner_upsert_invoice Active partner Own customer/booking; totals from line items; status DRAFT/ISSUED only; issued invoices immutable; atomic numbering (PTR-050)
partner_create_request Active partner Own customer or booking
public_b2b_offers, partner_hotel_offers, partner_seat_inventory Partner Offer lists without block cost, PNR, internal notes or other buyers (PTR-060)
public_groups, portal_context Anyone Open groups with published prices (no payer, lead staff or flights); caller kind for routing

4.6 Leave, holidays and the employee agreement

Rules: 22 · Leave and the employee agreement (LV-001 … LV-062, EA-001 … EA-011). Seeded by 20261003094100. Every route is one SECURITY DEFINER function that takes the caller from the session, checks the permission and the routing, refuses a paused login and writes the audit row; the requirePermission() calls in src/lib/leave.ts only shape the screen (ACC-001).

Module Permission Meaning Enforced at
leave leave.apply Apply for, report, withdraw and cancel one's own leave; read one's own balances, ledger and applications; attach one's own medical certificate. Every staff role; not TOUR_LEADER, AGENT or CUSTOMER (LV-001, LV-003) <ProtectedRoute> on /leave; leave_actor('leave.apply') in leave_apply, leave_preview, leave_my_dashboard, leave_my_ledger, leave_attach_certificate, leave_cancel; leave_employees() (accruals)
hr hr.leave.approve HR's approval of every application (except an HR applicant's own, which their reporting manager decides — LV-031); decide a cancellation asked for on leave that has started; read anyone's leave and register; see everyone in the team view (LV-030, LV-033, LV-055) leave_can_decide_hr() in leave_decide; leave_decide_cancellation; leave_may_read() in the row policies; leave_team, leave_register
hr.leave.approve_peak Management's second approval for peak season, the notice period and leave without pay (LV-032, LV-040, LV-041); see everyone in the team view leave_can_decide_mgmt() in leave_decide; leave_may_read(); leave_team
hr.leave.admin Leave admin: settings, leave types, peak seasons, the employee's leave record (confirmation, notice, weekly off), comp-off credits, adjustments, the accruals by hand, year end, the register; cancel anyone's leave with a reason; record an absence older than the reporting window (LV-033, LV-042, LV-050 … LV-062) <ProtectedRoute> on /leave/admin; leave_actor('hr.leave.admin') in leave_settings_save, leave_type_save, peak_season_save, peak_season_delete, hr_set_employee_leave_profile, leave_compoff_credit, leave_adjust, leave_run_accruals, leave_year_end_run, leave_hr_record_absence; leave_admin_screen, leave_year_end_preview, leave_register, leave_cancel
hr.holidays.manage Add, change and remove holidays in the holiday calendar; confirm a tentative date (LV-020, LV-021). Reading the calendar needs only a staff login leave_actor('hr.holidays.manage') in holiday_save, holiday_delete; holiday_list answers canManage
hr.agreements.issue Prepare, issue and withdraw an employee agreement; read every agreement (EA-002) agreement_prepare, agreement_issue, agreement_withdraw; row policy EmployeeAgreement select own or hr
hr.agreements.countersign Countersign an agreement the employee has signed, never one's own; read the agreements the employee has signed (EA-005, EA-010) agreement_countersign; the constraint EmployeeAgreement_not_self_check; row policy
hr.agreements.approve Approve or reject an agreement HR prepared, before the employee sees it; never one's own or one they prepared (EA-017) agreement_approve, agreement_reject; POST /agreements/:id/approve, /reject; <PermissionGate> on the agreement page; reads every agreement
hr.agreements.view Read every employee agreement and where each employee's stands (EA-008) <ProtectedRoute> on /hr/agreements; row policies on EmployeeAgreement and AgreementTemplate; agreement_get, agreement_hr_list
hr.profiles.edit Edit the profile details of any staff login that is not an admin account (ACC-084): designation, department, reporting officer, joined on, father's / guardian's name, date of birth, gender, blood group, personal email, address, district, state, PIN, emergency contact, notes, Aadhaar / PAN last four. Not the login's full name or work phone, not the employee code, nothing about access (roles, email, username, password, MFA, pause, deactivate). An admin account holds roles.apply or admin.permissions.edit (IT_ADMIN, SUPER_ADMIN today); one's own row stays on My profile. Also lets HR's agreement issue save the appointment back (EA-013). Seeded by 20261003235500 profile_edit_rights() in admin_update_employee_profile, agreement_profile_block (→ agreement_issue); profileEdit on admin_user_profile and employee_record; PATCH /users/:id/profile

4.7 Role requests

Rules: 08 · Access control (ACC-077 … ACC-080, ACC-082). Seeded by 20261003165000. A staff role — every role but CUSTOMER, AGENT and TOUR_LEADER — is added or removed only by a request that a CEO approves and an admin applies. Every step is one SECURITY DEFINER function that takes the caller from the session, checks the permission and the separation of duties, refuses a paused login and writes the audit row; the requirePermission() calls in src/lib/roleRequests.ts only shape the screen (ACC-001). The trigger UserRole_staff_role_lock refuses every other write of a staff role, whoever makes it (ACC-079).

Module Permission Meaning Enforced at
roles roles.request Ask for a staff role to be added to or removed from a login, with a reason; withdraw one's own request; read every request. Also needed to choose a role when creating a login (ACC-082). An administrative role (one whose bundle controls users, access, settings or role changes — role_is_administrative()) is asked for, added or removed, only by someone who also holds roles.apply (ACC-083) role_change_request_create / role_change_request_create_as (admin-users create_user, assign_role, remove_role), role_change_withdraw; row policy RoleChangeRequest select involved or permitted
roles.approve The CEO's approval: approve or reject (with a note) a request waiting for a CEO — never one's own request, never one about oneself (ACC-078) role_change_ceo_decide; the constraint RoleChangeRequest_ceo_not_requester
roles.apply The admin's decision: accept (the role changes, in the same transaction) or reject (with a note) an approved request — never one's own request, one about oneself, or one one approved. A role that carries admin.permissions.edit (SUPER_ADMIN) is applied only by someone who holds all of it. Held only by IT_ADMIN and SUPER_ADMIN (owner, ACC-083) — they alone may ask for an administrative role role_change_admin_decide — the only function the lock lets write a staff role; the constraint RoleChangeRequest_admin_separate

5. Role → permission grants

Canonical grants (as seeded by migrations through 20260416020000). Legend: ✓ = granted, - = not granted.

5.1 Super admin, leadership & admin tiers

Since 20260919100200_finance_role_bundles.sql (owner decision 2026-09-17) the tiers are:

Permission group SUPER_ADMIN CEO GM IT_ADMIN ADMIN_HR
every *.view and *.export ✓ ✓ ✓ admin.* only most (no finance / invoices)
business approvals — approvals.approve, bookings.cancel.approve, finance.refunds.approve, finance.journals.approve/reject/bulk_approve, finance.bookings.approve_finance/reject_finance, finance.cancellations.approve_airline/approve_b2b, group_invoices.issue/cancel, finance.periods.lock ✓ ✓ ✓ - -
finance.supplier_transactions.correct ✓ ✓ ✓ - -
finance.rates.manage — exchange rates (FIN-046, 20261006100000) ✓ ✓ ✓ - -
partners.credit.override (PTR-030, 20261006120000 — also FINANCE_MANAGER) ✓ ✓ ✓ - -
cancellation_policies.manage (20260923110000) ✓ ✓ ✓ - -
partners.approve, suppliers.approve — new partners and suppliers (PTR-095, PTY-009, 20261008120000) ✓ ✓ ✓ ✓ -
finance write (create / edit / payments / TDS / periods / config) ✓ - - - -
operational write (bookings, customers, groups, inventory, visa, tickets, communications…) ✓ - - - HR's existing bundle minus finance / approvals / invoices
system settings — users, MFA reset, integrations, audit, currencies, cities, admin.config.edit ✓ view only view only ✓ users only (no integrations secrets, no MFA reset)
duty of care — incidents.view / incidents.manage / incidents.close / incidents.confirm_death (file 13) ✓ ✓ ✓ - -
admin.permissions.edit, admin.roles.* (grant rights to others) ✓ - - - -
role requests (§4.7, 20261003165000) — roles.request / roles.approve / roles.apply all three request, approve request request, apply request
break-glass finance.journals.approve_own / finance.journals.reverse_own ✓ - - - -
destructive / system — finance.ledger.rebuild, finance.ledger.reset, finance.years.close, finance.periods.close, accounts.delete, admin.users.delete, agents.delete ✓ - - - -

Only SUPER_ADMIN holds every permission (ACC-012), and a trigger grants it every permission added later. Everyone else — CEO and GM included — holds a bundle; anything more is granted by the super admin through the Permissions matrix and recorded in the audit trail. Nobody grants a role directly any more: a staff role is requested, approved by a CEO and applied by an admin (§4.7, ACC-077). The no-outranking rule (20260917150000) still governs permission overrides, and for roles it survives in one place — a role that carries admin.permissions.edit (today SUPER_ADMIN) is applied only by an admin who holds every permission of it.

Maker-checker overrides. finance.journals.approve_own and finance.journals.reverse_own let the holder approve/reject or reverse a journal entry they created themselves, bypassing segregation of duties. They belong to SUPER_ADMIN only. FINANCE_MANAGER, ACCOUNTANT and CHARTERED_ACCOUNTANT must route their own vouchers to a different approver.

Duty of care (file 13). Incident records carry health data, so they are not part of the blanket super-admin bundle: IT_ADMIN and ADMIN_HR are not granted incidents.* by the seed (20260919130000_incident_permissions.sql). Management can grant them explicitly in Admin → Permissions if the duty roster needs it.

Who is the super admin. assign_super_admin() gives the role to the login admin@alhuda.co.in; if that user does not exist it falls back to everyone who currently holds IT_ADMIN so nobody is locked out, and says in a notice which rule applied. It never removes existing roles.

Where the roles and grants come from. 20260921110000_dr_seed_roles_and_permission_grants.sql inserts a Role row for every RoleType value and re-applies this whole matrix as an explicit manifest. Before it, only SUPER_ADMIN and CHARTERED_ACCOUNTANT had a row created by a migration; the other 18 existed only because someone made them through the app. Every grant migration seeds with INSERT ... JOIN public."Role" r ON r.name::text = g.role_name, so on a database built from migrations alone — a fresh project, or a restore from backup — that join matched nothing and every grant for those roles was skipped in silence: 2 roles and 204 grants instead of 20 and 949. supabase/tests/access_model.sql is the check; run it after every restore (see docs/operations/backup-restore.md).

Known inconsistency — IT_ADMIN and inventory. 20260920100200_inventory_drift_check.sql granted inventory.counters.repair to CEO, GM, IT_ADMIN, ADMIN_HR and OPS_MANAGER, after 20260919100200 had already narrowed IT_ADMIN to system settings. IT_ADMIN therefore holds an inventory right it cannot reach, because it has no inventory.view. Left as it is deliberately: granting the view would widen the role past the owner's 2026-09-17 decision. access_model.sql records this as its one documented exception to "whoever can act in a module can open it".

5.2 Functional staff (abbreviated — canonical bundles)

Role Grants
SALES_MANAGER bookings. (cancellation requests* only — no bookings.cancel.approve, no refund payout), customers.create/edit/delete, quotations.create, leads.edit, groups.create, groups.edit, inventory.holds.manage, reports.export, group_pricing.edit, group_invoices.create, group_invoices.edit, visa.intake.convert, communications.send + bookings.view, customers.view, quotations.view, leads.view, group_invoices.view, visa.groups.view, visa.intake.view
SALES_EXEC bookings.*, customers.create/edit, quotations.create, leads.edit, communications.send, inventory.holds.manage + bookings.view, customers.view, quotations.view, leads.view, group_invoices.view, visa.groups.view, visa.intake.view
B2B_MANAGER bookings. (cancellation requests only — no bookings.cancel.approve, no refund payout), partners. (excl. delete), agents.create, customers.create, quotations.create, reports.export, inventory.holds.manage, group_pricing.edit, group_invoices.create, group_invoices.edit + agents.view, bookings.view, customers.view, quotations.view, group_invoices.view
B2B_EXEC bookings.*, partners.edit, customers.create, quotations.create, inventory.holds.manage + agents.view, bookings.view, customers.view, group_invoices.view
OPS_MANAGER bookings.view, bookings.edit, bookings.cancel, bookings.cancel.approve, suppliers., hotels., food., inventory., inventory.counters.repair, inventory.holds.manage, inventory.release.request, inventory.release.approve, inventory.writeoff.approve, groups.create, groups.edit, reports.export, approvals.approve, visa.groups.edit, visa.intake.convert, communications.send, incidents.view, incidents.manage, incidents.close + bookings.view, inventory.view, approvals.view, group_invoices.view, visa.groups.view, visa.intake.view. No journal approval and no refund payout (finance.payments.refund, finance.refunds.approve)
OPS_EXEC bookings.view, bookings.edit, suppliers.edit, hotels.edit, hotels.export, food.edit, food.export, groups.edit, inventory.edit, inventory.holds.manage, inventory.release.request, visa.intake.convert, communications.send, incidents.view, incidents.manage + bookings.view, inventory.view, group_invoices.view, visa.groups.view, visa.intake.view
FINANCE_MANAGER every finance.* except the super-admin-only set (finance.journals.approve_own, finance.journals.reverse_own, finance.ledger.rebuild, finance.ledger.reset, finance.years.close, finance.periods.close) and finance.supplier_transactions.correct; bookings.cancel, bookings.cancel.approve, approvals.approve, reports.export, group_pricing.edit, group_invoices.create/edit/issue/cancel, suppliers.edit, inventory.writeoff.approve + finance.view, bookings.view, approvals.view, group_invoices.view
ACCOUNTANT finance.view, finance.create, finance.edit, finance.payments.record/verify/reject/refund, finance.journals.reverse, finance.supplier_transactions.correct, finance.rates.manage (20261006100000), finance.tds.view/deduct/export, finance.allocations., finance.stock_adjustment.post, finance.periods.create, finance report views+exports except P&L / balance sheet / group P&L, reports.export, group_invoices.create, group_invoices.edit, suppliers.edit + finance.view, bookings.view, group_invoices.view. No* approvals, refund approval, period lock/unlock/close, year close, config or numbering
FINANCE_MANAGER every finance.* except the super-admin-only set (finance.journals.approve_own, finance.journals.reverse_own, finance.ledger.rebuild, finance.ledger.reset, finance.years.close, finance.periods.close) and finance.supplier_transactions.correct; bookings.cancel, bookings.cancel.approve, approvals.approve, reports.export, group_pricing.edit, group_invoices.create/edit/issue/cancel, suppliers.edit, inventory.writeoff.approve, partners.credit.override (20261006120000, PTR-030) + finance.view, bookings.view, approvals.view, group_invoices.view
ACCOUNTANT finance.view, finance.create, finance.edit, finance.payments.record/verify/reject/refund, finance.journals.reverse, finance.supplier_transactions.correct, finance.tds.view/deduct/export, finance.allocations., finance.stock_adjustment.post, finance.periods.create, finance report views+exports except P&L / balance sheet / group P&L, reports.export, group_invoices.create, group_invoices.edit, suppliers.edit + finance.view, bookings.view, group_invoices.view. No* approvals, refund approval, period lock/unlock/close, year close, config or numbering
CHARTERED_ACCOUNTANT finance.view, finance.create (prepare journals — always approved by someone else), finance.journals.reverse, finance.supplier_transactions.correct, finance.tds.view, finance.tds.export, every finance.reports.*, reports.view, reports.export, admin.audit.view, dashboard.view + bookings.view, customers.view, groups.view, group_invoices.view, agents.view, inventory.view. No payment recording or verification, refunds, bank/config edits, numbering, period unlock or year close
CASHIER finance.payments.record + finance.view, and the general staff read bundle (bookings.view, customers.view, agents.view, groups.view, inventory.view, leads.view, quotations.view, requests.view, tickets.view, visa.view, approvals.view, dashboard.view, chat.view). No finance.create (journals, on-account receipts), TDS, opening balances or verification, and no write of any kind outside recording a receipt. The F1 bundles constrain finance.* exactly; the read bundle predates them and was left in place
TICKET_MANAGER tickets.edit, tickets.approve, bookings.edit, groups.edit, reports.export, inventory.view, inventory.edit, inventory.holds.manage, inventory.release.request, inventory.release.approve (AIR §30: this role owns airline blocks; before 20260920110200 it held no inventory permission at all), inventory.block_payments.record (20260929150000: it pays for its blocks, finance approves — FIN-044), inventory.b2b_cancellations.file (20260929200000: it files a sale's cancellation, finance decides — INV-014) + tickets.view, bookings.view, groups.view
TICKET_EXEC Exactly tickets.view, tickets.edit, bookings.view, bookings.edit, groups.view, inventory.view, inventory.create (20260930000000), inventory.edit, inventory.release.file (20260930030000), inventory.block_payments.record, inventory.b2b_cancellations.file, dashboard.view, work.view, work.create, work.claim, work.close (20260929150000, 20260929200000; asserted by supabase/tests/ticketing_pays_for_its_blocks.sql). AIR §30: the executive enters passenger info, allocates seats and updates PNRs (bookings.edit), edits blocks and sells seats on (inventory.edit), and records the airline's payment. No tickets.approve, no inventory.release.request / .approve, no inventory.create, no inventory.holds.manage — those stay with the manager
VISA_OFFICER visa.create, visa.edit, visa.groups.edit, visa.intake.convert, communications.send + visa.view, visa.groups.view, visa.intake.view, bookings.view
AUDITOR reports.export + all *.view (including incidents.view)

Module view permissions (20260921110000). reports.view, hotels.view and admin.view existed in no migration at all, and groups.view had reached the leadership tier only, so the bundles above described rights their holders could not actually use — a role with hotels.edit could not open /hotels, and nobody outside leadership could open /groups. All four now follow one rule: whoever holds a right in a module also holds that module's view permission. On top of the bundles above that means

  • reports.view → ACCOUNTANT, ADMIN_HR, B2B_MANAGER, CHARTERED_ACCOUNTANT, FINANCE_MANAGER, OPS_MANAGER, SALES_MANAGER, TICKET_MANAGER (everyone who holds reports.export)
  • hotels.view → OPS_MANAGER, OPS_EXEC, ADMIN_HR
  • groups.view → SALES_MANAGER, SALES_EXEC, B2B_MANAGER, B2B_EXEC, OPS_MANAGER, OPS_EXEC, FINANCE_MANAGER, ACCOUNTANT, VISA_OFFICER
  • admin.view → IT_ADMIN, ADMIN_HR, CHARTERED_ACCOUNTANT, OPS_MANAGER

plus CEO, GM, AUDITOR and SUPER_ADMIN, which hold every *.view already. agents.delete is the exception: it is destructive, so it stays with SUPER_ADMIN alone. Asserted by access_model.sql (ACC-049).

Write permissions no operational role held (20260922100100). An end-to-end replay of a real 27-pilgrim Umrah departure found three permissions whose routes exist and whose RLS names them, but which no role that does the work held. Each grant below is justified by a route in src/lib/api.ts:

  • groups.edit → OPS_MANAGER, OPS_EXEC, SALES_MANAGER, TICKET_MANAGER (it was ADMIN_HR + leadership only). Nine routes need it: PATCH /groups/:id; POST, PATCH and DELETE on /groups/:id/flights[/:flightId]; POST and DELETE on /groups/:id/misc-expenses[/:id]; POST and DELETE on /groups/:id/documents[/:docId]; and POST /sales/bookings/:id/move-group. Linking a flight to a departure is the one that mattered most — without it a departure's air cost never reached the books. TICKET_MANAGER owns airline blocks and PNRs (AIR §30), so the flight-link and PNR routes are its own; it also gains groups.view, which it lacked, under the module-view rule above.
  • bookings.edit → OPS_MANAGER, OPS_EXEC, TICKET_MANAGER. Operations could not assign a seat, a room, a meal plan or an airport transfer, and could not set a passenger's PNR (PATCH /sales/bookings/:id/passengers/:passengerId).
  • suppliers.edit → ACCOUNTANT, FINANCE_MANAGER. No finance role held it, so nobody in finance could record a supplier bill (POST /suppliers/:id/transactions), allocate against one, or correct a supplier record.

Nothing the F1 finance-controls work revoked comes back. None of the three is a finance.* permission, so none sits inside the exact finance families apply_role_bundles() rewrites, and none appears in fin_role_revokes() for a role granted here. suppliers.edit is deliberately not granted to CHARTERED_ACCOUNTANT (its bundle is an exact set — the grant would be reverted on the next apply_role_bundles()) or to CASHIER (no write beyond recording a receipt, ACC-020/021). A CA who needs to record supplier bills is a segregation-of-duties decision for the owner, not a quiet widening here. Asserted by supabase/tests/journal_write_paths.sql (FIN-076, FIN-077).

5.3 Portal roles

Role Staff-matrix grants
AGENT none (portal-scoped via route allowedRoles + ownership)
CUSTOMER none (portal-scoped via route allowedRoles + ownership)

5.4 Work inbox (20260926220000)

work.* is granted by permission, never by role name; SUPER_ADMIN receives each new row through the Permission_grant_super_admin trigger.

Permission Granted to
work.view, work.create, work.claim, work.close CEO, GM, SALES_MANAGER, SALES_EXEC, B2B_MANAGER, B2B_EXEC, OPS_MANAGER, OPS_EXEC, TICKET_MANAGER, TICKET_EXEC (20260929150000), VISA_OFFICER, FINANCE_MANAGER, ACCOUNTANT
work.view only (read the list, touch nothing) CASHIER (ACC-021/ACC-044: receipts only), ADMIN_HR, IT_ADMIN (ACC-046: system settings only), AUDITOR
work.view.queue, work.assign, work.reopen CEO, GM, SALES_MANAGER, B2B_MANAGER, OPS_MANAGER, TICKET_MANAGER, VISA_OFFICER, FINANCE_MANAGER
work.view.all CEO, GM, AUDITOR
work.queues.manage CEO, GM (20261008170000; named in their exact bundles in fin_role_bundle())
perf.view.own CEO, GM, SALES_MANAGER, SALES_EXEC, B2B_MANAGER, B2B_EXEC, OPS_MANAGER, OPS_EXEC, TICKET_MANAGER, VISA_OFFICER, FINANCE_MANAGER, ACCOUNTANT
perf.view.all CEO, GM (PERF-007: leadership only; queue-lead team views wait for queue members)

5.5 Field (20260928170100)

Permission Granted to
field.view, field.checkin TOUR_LEADER (and SUPER_ADMIN through the trigger)

Staff read check-ins through groups.view and may record one through groups.edit; they do not need field.*. Consent at booking (set_field_consent) needs bookings.edit or bookings.create, or being the booking's partner or customer.

5.6 Programme (20260928220000)

Permission Granted to
programme.manage SUPER_ADMIN, and every role that holds groups.edit at the time the migration runs: CEO, GM, SALES_MANAGER, OPS_MANAGER, OPS_EXEC, TICKET_MANAGER, ADMIN_HR
guide.religious.approve (20261008200000) SUPER_ADMIN only (the Permission_grant_super_admin trigger, and a backstop insert in the migration). No other role: the office grants it to named people as a user permission (Admin → Users → permissions) — TRV-019

The migration computes the list from groups.edit rather than naming roles, so a database where groups.edit was granted to another role by hand gives that role the guide too. Verified on a database built from migrations on 2026-09-25: the eight roles above. Live location (FLD-006) and passport submissions (TRV-005) add no permission: the traveller's own login, groups.view and bookings.edit decide.

5.7 Leave and the employee agreement (20261003094100)

Permission Granted to
leave.apply SUPER_ADMIN, CEO, GM, IT_ADMIN, ADMIN_HR, SALES_MANAGER, SALES_EXEC, B2B_MANAGER, B2B_EXEC, OPS_MANAGER, OPS_EXEC, FINANCE_MANAGER, ACCOUNTANT, CHARTERED_ACCOUNTANT, CASHIER, TICKET_MANAGER, TICKET_EXEC, VISA_OFFICER, AUDITOR — not TOUR_LEADER, AGENT or CUSTOMER (LV-003 is open)
hr.leave.approve, hr.leave.admin, hr.holidays.manage, hr.agreements.issue ADMIN_HR, SUPER_ADMIN
hr.leave.approve_peak, hr.agreements.countersign CEO, GM, SUPER_ADMIN
hr.agreements.view ADMIN_HR, CEO, GM, AUDITOR, SUPER_ADMIN
hr.agreements.approve (20261008125500, EA-017) CEO, SUPER_ADMIN — the owner: "CEO only"; named in the CEO's exact bundle in fin_role_bundle()
hr.profiles.edit (20261003235500, ACC-084) ADMIN_HR, SUPER_ADMIN

fin_role_bundle() holds exact bundles for CEO, GM, IT_ADMIN, CHARTERED_ACCOUNTANT and AUDITOR, and apply_role_bundles() deletes anything outside them. The migration re-creates fin_role_bundle() with the new permissions in those bundles (CEO and GM: leave.apply, hr.leave.approve_peak, hr.agreements.countersign; IT_ADMIN, CHARTERED_ACCOUNTANT and AUDITOR: leave.apply; hr.agreements.view reaches CEO, GM and AUDITOR through their share of every *.view), so a later run of apply_role_bundles() keeps them. The exact-bundle tests for CASHIER and TICKET_EXEC now include leave.apply: it is self-service on one's own leave, not a business write, so it does not widen what those roles can do to money or tickets.

5.8 Role requests (20261003165000)

Permission Granted to
roles.request ADMIN_HR, GM, CEO, IT_ADMIN, SUPER_ADMIN
roles.approve CEO, SUPER_ADMIN
roles.apply IT_ADMIN, SUPER_ADMIN

fin_role_bundle() is re-created with them: CEO and GM no longer share one exact bundle (the CEO's adds roles.approve), and IT_ADMIN's adds roles.request and roles.apply, so a later run of apply_role_bundles() keeps them. ADMIN_HR is not an exact bundle and fin_role_revokes('ADMIN_HR') does not name them. Before this migration nobody held these; a staff role was changed by whoever held admin.permissions.edit (SUPER_ADMIN) through the UserRole write policies, which are dropped.

5.9 New partners and suppliers (20261008120000)

Permission Granted to
partners.approve GM, CEO, IT_ADMIN, SUPER_ADMIN
suppliers.approve GM, CEO, IT_ADMIN, SUPER_ADMIN

The owner asked that new partners and suppliers be approved by "general managers, CEOs, or Admin only" (02/10/2026). Admin means IT_ADMIN and SUPER_ADMIN. fin_role_bundle() names both permissions in the leadership bundle (CEO, GM) and IT_ADMIN's, so apply_role_bundles() keeps them. ADMIN_HR, B2B_MANAGER and B2B_EXEC keep partners.edit (edit, suspend, deactivate, lift a suspension) but no longer approve a registration.

5.10 A partner's terms (20261008230000)

Permission Granted to
agents.credit_limit.set FINANCE_MANAGER, CEO, GM, SUPER_ADMIN
agents.commission.set FINANCE_MANAGER, CEO, GM, SUPER_ADMIN

The owner decided on 03/10/2026 that a partner's credit limit and commission are set by finance and leadership only (PTR-030). Until then ADMIN_HR and B2B_MANAGER held both; the migration revokes them from every role outside the four above. fin_role_bundle() names both in the leadership bundle (CEO, GM), and fin_role_revokes() names them for ADMIN_HR, SALES_MANAGER and B2B_MANAGER, so apply_role_bundles() keeps it so. A permission given to one person (UserPermission) is not touched. Without the permission, a member of staff can still add and edit a partner; its commission and credit limit stay 0 (new) or unchanged (existing).

6. Page-by-page action matrix

This is the inventory of every user-triggerable action, the permission it requires, and where the enforcement lives. The drift test confirms that every permission string here actually appears in code, and vice versa.

6.1 Sales → Customers (/sales/customers)

Section Action Permission Enforcement
Route View page customers.view <ProtectedRoute>
Header Sync customers customers.edit (should gate — currently none)
Header Export CSV customers.view client-only
Header Import CSV customers.create <PermissionGate> recommended
Header Create customer customers.create <PermissionGate>
Row Edit customer customers.edit <PermissionGate>
Row Delete customer customers.delete <PermissionGate>
Profile Upload doc to Drive customers.edit server-side
Profile Delete Drive doc customers.edit server-side
Profile View ledger customers.view server-side scoping
Profile → Activity tab View customer journey timeline customers.view (or admin.audit.view) get_audit_timeline RPC re-checks in DB
Row / Profile Open Customer 360 (/customers/:id) customers.view <ProtectedRoute>

6.1a Customer 360 (/customers/:id) — C360-001/003

One page per customer, all facts computed by get_customer_360 (20260919120000). The database returns only what the caller may see (staff by permission, a customer their own records — ACC-002/010) and lists what it left out in restricted; the page hides the same sections.

Section Action Permission Enforcement
Route View page customers.view <ProtectedRoute> + requirePermission on GET /journey/customers/:id
Emergency banner See that an incident affects a traveller (state only, no detail) customers.view DB: traveller state only; incident detail needs incidents.view (C360-003)
Now / Journey / Readiness Current trip stage, readiness checklist, next best action bookings.view DB + canSeeSection
Where they are today City, hotel, room, roommates, next movement bookings.view DB + canSeeSection
Itinerary Day-by-day flights, hotels, transfers, ziyarat bookings.view DB + canSeeSection
Trips All bookings with journey dots bookings.view DB + canSeeSection
Travellers & family Linked passengers, guardians bookings.view DB + canSeeSection
Documents / Visa & tickets Masked passport, expiry, visa and ticket status bookings.view DB + canSeeSection
Files The customer's documents, their visa documents and the live issued e-tickets/vouchers (GET /journey/customers/:id/files); open one in the viewer (drive-file) — ACC-074 customers.view (list); open: customers.view / visa.view / bookings.view by kind requirePermission + each part under its row security; drive-file per kind
Files Upload a customer document (upload-customer-doc) customers.edit edge function
Files A file the customer sent on WhatsApp shows From WhatsApp, its traveller and Awaiting review; OK / Reject (a reason) (POST /sales/customers/:id/documents/:docId/review → customer_document_review(), a paused login refused; COMM-035) customers.edit DB function + <PermissionGate>; a trigger refuses any other write of a document's source or review
Money Paid / due from verified payments and group invoices (never Booking.balanceAmount) finance.view or bookings.view DB re-checks; section null + restricted otherwise
Requests & messages Customer requests, message log, consent requests.view (messages: customers.view) DB re-checks
Health & special needs Restriction notice (no data recorded yet — HLT-003) customers.view canSeeSection
Activity Customer journey timeline customers.view (or admin.audit.view) get_audit_timeline RPC

6.2 Sales → Leads (/sales/leads)

Section Action Permission
Route View page leads.view
Header Create lead leads.edit
Detail Update status leads.edit
Detail Convert to quotation quotations.create
Detail Convert to booking bookings.create

6.3 Sales → Quotations (/sales/quotations)

Section Action Permission
Route View page quotations.view
Header Create quotation quotations.create
Row Edit quotations.create (treat as edit, currently same perm)
Row Send to customer quotations.create
Row Delete quotations.create (destructive — consider quotations.delete future)

6.4 Sales → Bookings (/sales/bookings) + Booking Detail (/sales/bookings/:id) + Wizard (/sales/bookings/new)

Section Action Permission
Route (list) View page bookings.view
Route (detail) View page bookings.view
Route (wizard) Create new bookings.create
List header Create booking bookings.create
List header Import bookings (CSV/XLSX) bookings.create
List row Print invoice bookings.view
Detail / List Edit booking — price, rates, GST, currency, customer, partner/source and travellers only while DRAFT, NEEDS_CORRECTION or REJECTED; contact details, notes and room choices any time (DB triggers "Booking_write_guard", "BookingPassenger_write_guard") — PRC-003 bookings.edit
Detail / List Add passengers (DB: adding or removing a traveller needs bookings.create or bookings.edit, restrictive policies on BookingPassenger, 20261007100000) bookings.edit
Wizard (create / edit) Override and save a partner booking that would take the partner over its credit limit — appears only after the save is refused; a reason is required and recorded (DB trigger "Booking_credit_limit", partner_credit_override in the audit trail) — PTR-030 partners.credit.override
Detail / List Send message / WhatsApp bookings.view
Detail / List Add payment (POST /finance/payments, recorded pending — ACC-021, FIN-032) finance.payments.record
Detail / List Request visa case visa.create
Detail / List Submit draft for approval (POST /sales/bookings/:id/submit → submit_booking) — LC-002 bookings.edit (DB: bookings.create or bookings.edit)
Detail / List Readiness check (GET /sales/bookings/:id/readiness) — PAX-004/011/020 bookings.view
Detail / List Send back (to ops) approvals.approve
Detail / List Resubmit after correction (returns to the stage that sent it back) bookings.edit
Detail / List Delete booking — DRAFT only, with no payments, carried money, transferred-in traveller, visa case or issued ticket; type booking number + reason (delete_booking, which also rejects or reverses the draft's vouchers; a browser hard delete is refused) — LC-021 bookings.delete
Detail / Wizard Move booking to another group (POST /sales/bookings/:id/move-group → move_booking_to_group) — INV-002 groups.edit (DB: groups.edit, booking.transfer or bookings.edit)
Detail Request cancellation (always a request; ACC-020) — request_booking_cancellation / request_passenger_cancellation record the requester; the browser cannot write a request bookings.cancel
Detail Approve cancellation request — not the requester; refund override set here bookings.cancel.approve
Detail Pay a refund on a cancelled booking (POST /sales/bookings/:id/refund → request_booking_refund) — requested here, paid when finance.refunds.approve approves it finance.payments.refund (was bookings.cancel.approve)
Detail Transfer passenger to another group, optionally at a new price with a reason (POST /sales/bookings/:id/passengers/:pid/transfer → transfer_passenger_to_booking) — LC-030, PRC-005. No discount limit yet (PRC-002 is open) booking.transfer (DB: booking.transfer)
Detail → Activity tab View booking journey timeline (passengers, seats, rooms, visa, payments, vouchers) bookings.view (or admin.audit.view)
Detail Journey header — stage, branch, readiness checklist, next best action (GET /journey/bookings/:id → get_booking_journey) — JRN-001/003, INT-103 bookings.view (DB re-checks; a customer/partner sees only their own)
Detail Customer ledger customers.view
Detail → Flights Assign / update / unassign a passenger's flight leg (seat, fare class, ticket no, PNR) bookings.edit (DB: BookingPassengerFlight RLS)
Detail → Hotels Assign / update / unassign a passenger's stay (room number, share group, dates, meal plan) bookings.edit (DB: BookingPassengerHotel RLS)
Detail → Meals Assign / unassign a passenger's meal plan bookings.edit (DB: BookingPassengerMeal RLS)
Detail → Ground Assign / unassign a passenger's airport transfer bookings.edit (DB: BookingPassengerGroundService RLS)
Detail → Family / Wizard (after the booking is made) See who is family on this booking (GET /families/booking/:id → booking_families) — PAX-036 bookings.view (DB)
Detail → Family / Wizard "Who is family here?" — All one family (pre-fill from GET /families/booking/:id/prefill, then POST /families → family_save), Split into families, None are family (POST /families/status → travellers_set_family_status), Decide later. Optional; never blocks submission or approval — PAX-036 bookings.edit (DB: bookings.edit, or the partner who owns the travellers)
Detail → Family Set a child's or infant's guardian — an adult on the same departure, any booking (POST /families/guardian → traveller_set_guardian) — PAX-021 bookings.edit (DB)

6.5 Groups (/groups)

Section Action Permission
Route View page groups.view
Header Create group groups.create
Header Create from template (list page) groups.create
Header → More Clone group (detail page); the programme comes too (trv_clone_programme, which checks groups.create itself — INV-003, INV-008) groups.create
Header → More Save as template (detail page) groups.create
Card / Header → More Delete group groups.delete
Header → More Print group report; Rooming list / Flight manifest / Meal count exports groups.view
Print group report Include money — each booking's total, paid and due, the group's totals, filter all / fully paid / balance due; the printout is marked internal (INV-007). Screen gate only: the figures are the ones the page already read bookings.view or finance.view (<PermissionGate>)
Passenger Mark group leader groups.edit
Header Edit group — name, codes, dates, capacity, status override, service charge per traveller (FIN-039; reason when the status or the charge changes) groups.edit
Overview → Sales status Stop selling / Reopen sales / Mark departed / Mark completed / Back to automatic (PATCH /groups/:id statusOverride, reason — INV-006) groups.edit
Overview → Group Leader / Passengers → a traveller Name or remove the group leader — one of the group's own travellers (POST /groups/:id/leader → set_group_leader, audited — PAX-035) groups.edit (simple Yes/No)
Passengers → a traveller Transfer to another group (the booking page's transfer dialog, POST /sales/bookings/:b/passengers/:p/transfer) — PAX-035, LC-030 booking.transfer
Passengers → a traveller Remove from group → Cancelling their trip (the booking page's passenger cancellation request; approved by someone else with bookings.cancel.approve) or → Moving to another group (the transfer) — PAX-035, LC-020 bookings.cancel / booking.transfer
Passengers → bulk bar Assign hotel / meal / ground service to the travellers picked (POST /groups/:id/travellers/place → place_travellers) — PAX-035 bookings.edit
Passengers → bulk bar Link flight to the travellers picked (one seat request per traveller) bookings.edit
Passengers → bulk bar Request visa for the travellers picked (one case per traveller, POST /visa with bookingPassengerId) visa.create
Passengers → By family See the departure's families, "Not family" and "Family not recorded"; readiness counts (not recorded, no guardian, family needs a head) — PAX-036, PAX-021 groups.view (families read with the rights that read BookingPassenger)
Passengers → By family New family / Edit family / Add to family / Treat this booking as one family (POST /families → family_save) — PAX-036 bookings.edit (DB)
Passengers → By family Remove from family, optionally marking them not family (POST /families/:id/remove-member); Dissolve a family with a reason (POST /families/:id/dissolve) — PAX-036 bookings.edit (DB)
Passengers → a traveller Mark not family / Undo (POST /families/status) — PAX-036; Set guardian for a child or infant (POST /families/guardian) — PAX-021 bookings.edit (DB)
Exports Rooming list and flight manifest show each traveller's family and relationship — PAX-036 groups.view
Overview → Tour Leader Search who can be appointed (GET /groups/tour-leader-candidates?q= → tour_leader_candidates; employees, partner logins, traveller logins — FLD-007) groups.edit
Overview → Tour Leader Appoint (POST /groups/:id/tour-leader → appoint_tour_leader: grants TOUR_LEADER and nothing else, sets leadUserId, audited, notified) groups.edit (simple Yes/No)
Overview → Tour Leader Dismiss (DELETE /groups/:id/tour-leader → dismiss_tour_leader: clears leadUserId, removes TOUR_LEADER when no other group is led) groups.edit (simple Yes/No)
Booking dialog Move whole booking to another group (move_booking_to_group, reason, capacity checked) groups.edit
Booking dialog Drop whole booking from group (move_booking_to_group with no target, reason) groups.edit
Header Assign existing booking to this group (move_booking_to_group) groups.edit
Itinerary tab See the day-by-day planner, the Timeline and the traveller preview (GET /groups/:id/programme → trv_trip_programme) — INV-008 groups.view
Itinerary tab Add, edit, duplicate, move up/down, move to another day and delete an activity (POST /groups/:id/activities, POST /groups/:id/activities/reorder, DELETE /groups/:id/activities/:activityId → trv_upsert_activity, trv_reorder_activities, trv_delete_activity, which check trv_can_write_group) — INV-008, TRV-003 groups.edit (<PermissionGate> + route + DB; simple Yes/No on delete)
Itinerary tab Save programme as template; start from or add from a template; rename and delete a template (POST /programme-templates, POST /programme-templates/:id/apply, PATCH/DELETE /programme-templates/:id → programme_template_save, programme_template_apply, programme_template_update, programme_template_delete, which check groups.edit themselves) — INV-009 groups.edit (<PermissionGate> + route + DB; simple Yes/No on add-to-existing and delete)
Itinerary tab See the templates and their items (GET /programme-templates → programme_templates(); RLS on ProgrammeTemplate, ProgrammeTemplateItem) — INV-009 groups.view
Itinerary tab Edit planned stays — opens Edit group on the planned hotel stops and transfers groups.edit
Flights tab Link flight groups.edit
Flights tab Unlink flight groups.edit
Hotels tab Assign hotel groups.edit
Ground tab Assign transfer groups.edit
Ledger tab View this departure's own vouchers (GET /groups/:id/ledger, FIN-035) groups.view and finance.view — seeing a departure is not the same right as seeing its money
Pricing tab View rate sheet groups.view
Pricing tab Edit line items + tax lines (save) group_pricing.edit
Pricing tab Refresh rates from inventory group_pricing.edit
Pricing tab Copy from another group — list the departures with a sheet and copy one onto this departure (GET /groups/:id/pricing/copy-sources, POST /groups/:id/pricing/copy; copy_group_pricing also needs groups.view, PRC-006). The phone's Copy pricing from another group on the group screen is the same right group_pricing.edit
Pricing tab Compare with inventory cost — cost and margin beside each price, nothing changed (PRC-007) group_pricing.edit
Pricing tab Use cost as price (confirmed; saved only by Save) group_pricing.edit
Invoices tab View payers + their invoices group_invoices.view
Invoices tab Create draft invoice for a payer group_invoices.create
Invoices tab Edit draft invoice group_invoices.edit
Invoices tab Issue draft → ISSUED (numbered, locked; posts nothing) group_invoices.issue
Invoice dialog Post to finance — revenue + tax journal; says Nothing to post when the bookings already posted their revenue (FIN-036) group_invoices.issue
Invoices tab Create supplementary for issued invoice group_invoices.create
Invoices tab Cancel issued invoice (credit note) group_invoices.cancel
Templates list View templates groups.view
Templates list Instantiate (create group from template) groups.create
Templates list Save / delete template (GroupTemplate) groups.create (DB: GroupTemplate RLS)
Activity tab View group timeline (bookings, passengers, inventory, visa, finance) groups.view (or admin.audit.view)
Passengers tab Traveller journeys + group readiness (GET /journey/groups/:id → get_group_journey) — JRN-001/003, INT-100 bookings.view (<PermissionGate> + DB)

6.5a Journey board (/operations/journey) — JRN-003, INT-100/103

Section Action Permission Enforcement
Route View board bookings.view <ProtectedRoute> + requirePermission on GET /journey/board
Board Groups in travel or departing within N days, readiness %, blockers, next best action bookings.view get_journey_board returns [] without it
Dashboard widgets Readiness blockers · departures next 30 days · travellers in travel by city bookings.view journeyWidgets[].anyPermission + DB

6.5a Operations → Duty of care (/operations/incidents)

Incidents (file 13). Reads need incidents.view — the same permission gates the health details on an incident (C360-003). Every action is a SECURITY DEFINER function that re-checks the permission, and every one is confirmed Yes/No (UX-001).

Section Action Permission Confirmation
Route View the incident board (INC-020) and an incident's detail incidents.view —
Header Report incident (open_incident; Critical records the management escalation) incidents.manage simple
Drawer Add update / log call / record a visit (add_incident_update) incidents.manage simple
Drawer Upload a document to the company Shared Drive (drive-upload, kind incident_doc) and record it (add_incident_document); open one in the viewer (drive-file) incidents.manage (upload), incidents.view (open) simple
Drawer Checklist step → in progress / done (update_checklist_item) incidents.manage simple
Drawer Checklist step → blocked / not applicable, or undoing a finished step (INC-010 step 12) incidents.manage reason
Drawer Add a suggested task from the impact assistant (INT-141) (add_incident_checklist_item) incidents.manage simple
Drawer Assign owner / family liaison / escalation contact (assign_incident_owner) incidents.manage reason
Drawer Edit severity, place, restricted details, costs and who bears them (update_incident_details; recorded only, never posted to the ledger) incidents.manage reason
Drawer Status → In progress / Resolved (change_incident_status) incidents.manage reason
Drawer Close, or reopen a closed incident (change_incident_status; every step done or not applicable) incidents.close typed (incident number)
Drawer Traveller state → hospitalised / missing / found / discharged (set_traveller_state) incidents.manage reason
Drawer Traveller state → deceased, or a correction of it (set_traveller_state, INC-012) incidents.confirm_death typed (incident number)
Booking / group / Customer 360 Open-emergency banner and traveller flags (incident_banner, group_traveller_states) incidents.view for the detail; bookings.view / groups.view / customers.view see only that an incident exists (C360-003) —
Pickers Traveller and group search, assignable staff (incident_lookup, incident_assignable_staff) incidents.manage —
Dashboard Incident widgets (open by severity, needing an owner or update, open critical) incidents.view / incidents.manage —

6.5b Field app (/field) — FLD-001 … FLD-005

Section Action Permission Confirmation
Route My groups, one group's manifest / rooms / plan / log field.view (own groups only; groups.view opens any group's check-ins on the Groups page) —
Group Record a check-in (fld_record_checkin) field.checkin for a group I lead; groups.edit for staff — (a check-in is a record of fact; saved on the phone first, sent once)
Booking wizard / partner wizard / booking page Record the travellers' answer on location sharing (set_field_consent) bookings.create / bookings.edit, or the booking's partner or customer simple (booking page switch)
Booking wizard / partner wizard / booking page Record the booking's emergency contact (set_booking_emergency_contact, INC-005) bookings.create / bookings.edit, or the booking's partner or customer — (a fact about the booking)
Group → SOS tab Read the emergency contact of every traveller on a group I lead, and call it field.view for the group's leader; bookings.view / incidents.view for staff —
Groups → Overview Last check-in and missing travellers (fld_group_checkins) groups.view —
Customer 360 Latest check-in in "where they are today" customers.view —

6.5c Phone app (/m/**) — PLT-060

The same actions as the desktop screens, on the same routes and functions; nothing is gated more loosely on a phone.

Section Action Permission Confirmation
Route /m, /m/more, /m/notifications, /m/approvals staff sign-in; each count and queue appears only for the permission it needs —
Route /m/bookings, /m/bookings/:id bookings.view —
Route /m/customers customers.view —
Route /m/groups (opens the field screen) groups.view —
Booking Receive payment (POST /finance/payments, recorded pending — FIN-032) finance.payments.record reason (the receipt note)
Booking / Approvals Approve the sales check (ops_decide_booking) or finance (finance_decide_booking) approvals.approve simple
Booking / Approvals Send back (ops_decide_booking / finance_decide_booking with send_back) approvals.approve reason
Bookings New booking (opens the wizard) bookings.create —
Notifications Switch alerts on or off for this phone or browser (push-subscribe) any signed-in user, own device only —
Notifications The inbox of kept notifications: read, mark read, mark all read, the unread count (AppNotification RLS, ntf_mark_read, ntf_mark_all_read, ntf_unread_count — TRV-010) any signed-in user, own rows only; no staff permission —

6.5d Native app (apps/mobile) — TRV-001 … TRV-014, FLD-006

One Expo app, three stacks chosen from the login (20 · The traveller's app). Every screen calls the database functions below; the app hides what the person may not do and the function refuses the rest. Nothing is gated more loosely than on the web.

Stack Screen Action Permission / function Confirmation
Staff Home Counts and departures (badge_counts, dash_*) each only for the permission it needs, as on /m (§6.5c) —
Staff Home → My dashboard The website's role dashboards on the phone (UX-010, UX-011), opened from Home — never a button in the tab bar (UX-024): one tab per matched role profile, main first, with the website's suppression and priority (src/lib/dashboardProfiles.ts, copied byte for byte to apps/mobile/src/lib/dashboardProfiles.ts); each tab is one dashboard_screen call (PRF-002), as on /app no permission of its own: the Home row shows when the login's permissions match at least one profile detector (§6.18); each widget needs one of its own permissions (§6.18) and each tile its dash_* function's own check — a tile the database refuses is not drawn —
Staff Dashboard → a widget's title or row Opens the phone screen behind it (Approvals, Groups, Bookings, Visa, Tickets, Airline blocks, Seat releases, Leads, Partners, Finance, My receipts, Users; a departure opens its readiness, a row its booking, group, block, visa case, ticket sheet or task); no link where the phone has no screen that screen's own permission, checked before the link is offered (e.g. Finance needs the finance jobs of financeAccess, Users admin.users.view); the screen checks again —
Staff Home / Dashboard → Readiness One departure: who is missing finance clearance, payment, visa, ticket or room (app_departure_readiness, INT-100); a row opens the booking; Call / WhatsApp the traveller or customer any of groups.view, approvals.approve, hotels.edit, inventory.edit, visa.edit, tickets.approve; amounts need finance.view or bookings.view, phones bookings.view or customers.view (null otherwise) —
Staff Bookings, Booking List and open a booking; receive a payment (record_payment, pending — FIN-032); approve or send back (ops_decide_booking, finance_decide_booking); record the emergency contact (set_booking_emergency_contact) bookings.view; finance.payments.record; approvals.approve (or finance.bookings.approve_finance); bookings.edit / bookings.create reason on send back and on the receipt note
Staff Booking → Actions → Edit room and payment The room choice and, on a direct booking, part or full payment (update_booking with only those fields and no travellers — PRC-003); not on a cancelled or transferred booking bookings.edit (checked in the function) —
Staff Booking → Actions → Ask to cancel the booking; a traveller → Ask to cancel this traveller A request only (request_booking_cancellation / request_passenger_cancellation stamp the requester — LC-020, ACC-020); not on a draft, nor while one waits bookings.cancel (checked in the function) reason
Staff Booking → cancellation waiting → Reject app_reject_cancellation (whole booking) — LC-020 bookings.cancel.approve (checked in the function) reason
Staff Booking → Actions → Ask for a refund When the cancelled travellers' refunds owe money: refund_preview then request_booking_refund (paid from the account the money came into; capped — PRC-030; someone else approves — CXL-003) finance.payments.refund (checked in the function; the preview needs finance view) reason
Staff Booking → a traveller → Open visa case The traveller's case (VisaCase RLS, then the case screen) — VISA-001. Opening a new case stays on the website visa.view —
Staff Booking → Documents (app, and the desktop booking page's Documents card) The issued e-ticket sheet and hotel voucher(s) of the booking (IssuedDocument RLS), open one (drive-file, kind issued) bookings.view —
Staff Booking → Documents → Issue e-ticket Render, store and record the company's e-ticket sheet for the booking (edge function issue-document, kind eticket → issue_document_record; TRV-013). Refused by name while any traveller ticketed by Alhuda lacks an issued ticket number tickets.view and bookings.view (both checked in the function) Yes/No; a re-issue says it replaces the travellers' sheet
Staff Booking → Documents → Issue hotel voucher One voucher per hotel stay of the booking (issue-document, kind hotel_voucher) hotels.view and bookings.view (both checked in the function) Yes/No
Staff Find (Home box, More → Find anyone) Search bookings (booking_page), customers (customers_list_page), partners (Agent) and groups (TravelGroup) at once; open the result's own screen (PTY-007) each section only with its view permission: bookings.view, customers.view, agents.view, groups.view; the reads' own checks and row security decide —
Staff Customers, Customer The customers list (customers_list_page) and one customer: code, contact, trips, money, the current trip's passport / visa / ticket, travellers, files (customer_360_screen + CustomerDocument RLS; a file opens through drive-file, kind customer_file) (PTY-007) customers.view (checked in customer_360_screen and drive-file); money needs finance.view or bookings.view and trips bookings.view (decided in get_customer_360) —
Staff Customer, Booking travellers, Partner record, Find rows Call and WhatsApp: open the phone's dialer and WhatsApp with the number shown (+91 for a ten-digit number); nothing is sent or recorded (PTY-008) none beyond reading the record —
Staff Booking → Payments → Receipt (app, and the desktop booking page's Payments tab) The receipt of a verified payment: the live one, or rendered, stored and recorded now (issue-document, kind receipt → issue_document_record; FIN-045); opened through drive-file (kind issued; IssuedDocument RLS lets finance.view read receipts). Refused for a payment that is not verified finance.view or bookings.view (checked in the function); a re-issue (reissue) is staff only — (a receipt does not change; nothing is replaced unless reissue is asked)
Staff, Partner, Traveller A document's sheet → Share / WhatsApp Share the PDF of an issued e-ticket sheet, hotel voucher or receipt from the phone; WhatsApp the customer a note (TRV-015). The file is fetched through drive-file first, so its checks decide whoever may open the document; nothing new on the server —
Staff Approvals The tab — or, when the bar already has Bookings, Groups and Operations, a row on Home with the number waiting (UX-024): everything dash_approvals_inbox gives the person (ACC-020); shown to anyone holding one of the function's nine rights any of approvals.approve, finance.bookings.approve_finance, finance.payments.verify, finance.journals.approve, finance.refunds.approve, bookings.cancel.approve, finance.cancellations.approve_b2b, finance.cancellations.approve_airline, tickets.approve —
Staff Approvals → Sales check / Finance check Open the booking; approve and send back there approvals.approve (or finance.bookings.approve_finance) simple / reason
Staff Approvals → Receipts to verify Verify / reject a receipt (verify_payment; never the recorder — FIN-032) finance.payments.verify (reject also finance.payments.reject) note optional / reason
Staff Approvals → Vouchers Open the voucher's lines; approve / reject (approve_journal_entries, approver ≠ maker, period lock). A voucher with pendingMeta follow-up steps is approved on the desktop only finance.journals.approve / finance.journals.reject note optional / reason
Staff Approvals → Ticket exceptions Approve / refuse (decide_ticket_exception, never the requester) tickets.approve reason
Staff Approvals → Refunds Approve / reject a refund someone else asked for (approve_refund: never the requester, capped at what can still be refunded — PRC-030, ACC-030 limit; approving posts the refund voucher and the negative payment); on approval the customer's refund_notice e-mail through mailer, best effort — CXL-003, #559 step 4 finance.refunds.approve (checked in the function) note optional / reason
Staff Approvals → Cancellations Open the booking; Reject the request (app_reject_cancellation, audited, the requester told — LC-020). Approving stays on the website bookings.cancel.approve (checked in the function) reason
Staff Approvals → Airline and seat-sale refunds Listed only; decided on the desktop as the inbox —
Staff Home → My work; More → Tasks The work inbox (work_inbox_page: Mine, To pick up; Team = queues / everything); Given by me (work_given_by_me) — WRK-001 … WRK-018 work.view; Given by me work.create; Team work.assign with work.view.queue / work.view.all —
Staff Tasks → a task Details and ledger (work_item_get); take it (work_item_claim); first answer / note (work_item_record_response); park / restart (work_item_wait / _resume); hand over (work_item_assign; list work_people, suggestions suggest_work_assignee); put back (work_item_release); finish (work_item_close done); cancel (work_item_close cancelled); reopen (work_item_reopen) take work.claim; answer / park / restart / hand over: the holder with work.claim, or work.assign; put back: the holder with work.claim; finish work.close (holder or work.assign); cancel work.assign; reopen work.reopen; suggestions work.assign reason on park, hand over, put back, finish, cancel, reopen
Staff Tasks → New task Type a task (work_item_create, WRK-018): title, details, due time, priority, a linked booking / customer / group / partner; hold it myself or leave it in the pool; give it to a colleague; the holder is told (AppNotification, a push from the server and an e-mail — WRK-018, COMM-041) work.create; hold it myself work.claim; give it to someone else work.assign; searching a record needs that record's view permission —
Staff More → My performance; Tasks → chart The scorecard (perf_scorecard), the jobs behind a figure (perf_scorecard_rows), choose a colleague (perf_people) — PERF-001 … PERF-012 perf.view.own; another person perf.view.all (each look audited, PERF-011) —
Staff Home / More → Leads The leads (Lead RLS), call / WhatsApp; change status (lead_set_status: lost / closed need a reason, converted stays — SAL-001) leads.view; leads.edit (checked in the function) reason on lost / closed
Staff Home / More → Visa cases The cases, by stage and departure, with a search (visa_phone_cases, VISA-032); the next forward step with a reason (change_visa_status, VISA-003 — the customer is e-mailed on issued / refused, VISA-005) visa.view; visa.edit reason
Staff Visa cases → Case The case, its documents (open: drive-file kind visa_file) and history (visa_case_screen); Add visa copy or a paper (drive-upload kind visa_doc, then add_visa_document — VISA-031) visa.view; visa.edit to add —
Staff Home / More → Tickets → Departure The departures and one sheet (ticketing_departures, ticketing_flight_sheet); enter one traveller's ticket number (issue_tickets); ask for an exception (request_ticket_exception) — AIR §24 tickets.view; tickets.approve; tickets.edit reason
Staff Tickets → Departure → Paste from the airline Paste the airline's list; it is read with the website's reader (src/lib/ticketPaste.ts, copied to the app and held equal by a test) — numbers in the order of the Ready travellers, or name and number pairs; the preview shows each number against its traveller, wrong ones in red, lines matched to nobody; Save sends every number in one issue_tickets call, all or nothing — the website's POST /tickets/flights/:id/issue (AIR §24, #559 step 5) tickets.approve reason (where the numbers came from)
Staff Home / More → Receipts I recorded The receipts this login recorded and their status (Payment RLS, createdBy = me) finance.payments.record —
Staff Home / More → Finance The finance area: my vouchers waiting (dash_my_journals), the queue's size (badge_counts). Shown to finance staff who write, approve or reverse vouchers — not to everyone who reads finance finance.view and one of finance.create, finance.journals.approve, finance.journals.reverse —
Staff Finance → New entry Write a journal / payment / receipt / contra voucher: type, date, narration, lines from the account picker (active leaf accounts in rupees; party ledgers by party code) → create_manual_journal: always pending, maker = caller, balanced to the paisa, no future date, open period, rupees only, idempotent for ten minutes, audited (FIN-032, ACC-030) finance.create (checked in the function — the same right as POST /finance/journals) Yes/No showing every line
Staff Finance → New entry / voucher → Attach a bill or photo Store the file on the company drive (drive-upload, kind voucher_attachment) and link it (JournalAttachment RLS) finance.edit (the edge function); the link finance.create or finance.edit —
Staff Finance → Vouchers, voucher The recent vouchers, mine or everyone's, by status (JournalEntry RLS); one voucher with lines, makers and files (finance_journal_screen); open a file (drive-file, kind file) finance.view —
Staff Finance → voucher → Approve / Reject approve_journal_entries; offered only on a pending voucher someone else wrote (the function refuses the maker unless finance.journals.approve_own); one with website follow-up steps is approved on the website finance.journals.approve / finance.journals.reject note optional / reason
Staff Finance → voucher → Reverse A posted hand-made voucher, not a reversal, not reversed yet, not by its maker (unless finance.journals.reverse_own, as on the website) → post_journal_reversal, pending, with the reason in the memo finance.journals.reverse reason
Staff Finance → Account ledger One account's statement between two days, opening and closing balance (finance_account_ledger) finance.view (checked in the function) —
Staff Groups, Group The departures; a group's people, rooms, plan, log, SOS (fld_group_manifest, fld_group_checkins); record a check-in (fld_record_checkin); edit the programme and post a notice (trv_upsert_activity, trv_delete_activity, trv_post_notice); where the sharing travellers are (fld_group_locations) groups.view to open; groups.edit to record, edit and post; groups.view for positions —
Staff Group → Tour leader See who leads, call them; Appoint / Change through the candidates search (tour_leader_candidates, chips Employees / Partners / Travellers); Dismiss (dismiss_tour_leader) — FLD-007 groups.edit (the functions check it) Yes/No
Staff Group → Tour leader → Invite a new person Create a login for someone who is not an employee, partner or traveller yet (edge function admin-users create_user with allowNonStaffEmail), then appoint them (appoint_tour_leader); the temporary password is shown once; a password-reset email is requested for them (auth-login reset); a WhatsApp note (never the password) goes through whatsapp-send when the inviter may send and the number is inside WhatsApp's 24-hour window groups.edit and one of admin.users.create, admin.users.edit, customers.create, customers.edit, partners.create, partners.edit (the edge function's own rule); communications.send for the WhatsApp note Yes/No
Staff Group → Manifest ⋯ → Make group leader (one traveller per group; not the tour leader) — PAX-035 groups.edit · set_group_leader Yes/No
Staff Group → Manifest ⋯ → Transfer to another group (always a new booking on the target group, sent for approval; optional new price; reason) — LC-030, PRC-004/005, INV-020 booking.transfer · transfer_traveller_to_group reason
Staff Group → Manifest ⋯ → Remove from group → Cancelling their trip (a request only; approved by someone else on the desktop) — LC-020 bookings.cancel · request_passenger_cancellation reason
Staff Group → Manifest ⋯ → Remove from group → Moving to another group (opens the transfer) booking.transfer · transfer_traveller_to_group reason
Staff Group → Services Place travellers into a hotel / meal plan / transfer (tick by booking; optional room-share label or meal plan) — PAX-035 bookings.edit · place_travellers (the screen itself opens with groups.edit) Yes/No
Staff Group → Manifest → By family Families, "Not family", "Family not recorded" and the readiness line (fld_group_manifest carries them); New family / Edit family, Mark not family, Set guardian — PAX-036, PAX-021 groups.view to see; bookings.edit · family_save, travellers_set_family_status, traveller_set_guardian to change Yes/No
Staff New booking → after it is made "Who is family here?" — All one family / None are family / Decide later (optional) — PAX-036 bookings.edit · family_prefill_from_booking, family_save, travellers_set_family_status —
Partner Booking → Family The booking's families and guardians; the same question; set a guardian — own travellers only — PAX-036, PAX-021 the functions check the partner owns the travellers —
Leader Group → Manifest No per-traveller actions field.checkin only —
Leader Group → Manifest → By family The families, read only — PAX-036 field.view for a group I lead —
Staff More → Groups I lead A member of staff appointed tour leader: the departures they lead (fld_my_groups), with or without groups.view; a group opens the tour leader's screen — FLD-007 TOUR_LEADER on the login; hidden for every other login —
Traveller My trip → Groups I lead; Me → Groups I lead The departures this traveller has been appointed tour leader of (fld_my_groups), the outbox of unsent check-ins; a group opens the tour leader's screen (§6.5b) — FLD-007 TOUR_LEADER on the login (field.view, field.checkin for a group I lead); hidden for every other traveller —
Staff More → Scan a passport Read a passport with the phone (extract-passport, or the MRZ on the phone) and attach it to a passenger as a pending submission (trv_submit_passport), or copy the fields bookings.edit (staff may submit for any passenger); the desktop reviews — (a submission changes nothing until applied)
Staff Booking → passport submissions Apply or reject a scan (trv_review_passport_submission) bookings.edit simple
Staff More → Guide for travellers Write, edit, deactivate and delete guide steps (trv_upsert_guide_step, trv_delete_guide_step); mark a step or part Everyone / Men / Women and Both / Sunni / Shia; add, change and delete parts — text, verse, dua (trv_upsert_guide_part, trv_delete_guide_part); write Urdu, Hindi, Kashmiri, Arabic by hand (trv_save_guide_translation); read everything with its status and approver (trv_guide_editor) — TRV-018 … TRV-021 programme.manage; the row also shows for guide.religious.approve (read-only editor) simple on delete
Staff More → Guide for travellers → Approve / Discard the change Approve the text waiting on a step or part in a language — it replaces the approved text travellers read (trv_guide_set_status approved): religious text (verse, dua, their meaning, anything Sunni or Shia) — TRV-019; plain text — TRV-018. Discard a waiting change (discard) religious: guide.religious.approve, and not the person who wrote or last changed it (maker-checker; Approve shown disabled with the reason); plain: programme.manage, one person; discard: programme.manage Yes/No on approve
Staff More → Guide for travellers → Preview as The guide as a traveller of a chosen language, tradition and gender reads it, with Show both (trv_guide_preview) — TRV-020, TRV-021 programme.manage or guide.religious.approve —
Staff / Partner More → My profile Own profile (profile_me); staff change their contact fields (profile_update_me) and file documents (upload-employee-doc → employee_add_document) — ACC-071; a partner changes its address and website (partner_update_my_profile) and adds, changes or removes its contacts (partner_contact_save / partner_contact_remove, own agency only) — PTR-086 any signed-in login; a paused one reads only (ACC-070) simple on save
Staff More → Users Search logins with status, access and roles (admin_users_list); open the employee record (employee_record: profile, documents with review, access, activity — ACC-071); pause with a reason / resume (admin_pause_user / admin_resume_user) — ACC-070; review a document (employee_document_review) admin.users.view to see; admin.users.edit to pause, resume or review reason + Yes/No on pause; Yes/No on resume; note on a rejection
Staff More → Role requests The requests waiting for the CEO or an admin, mine, and history (role_change_requests); Approve / Reject (role_change_ceo_decide), Accept and apply / Reject (role_change_admin_decide) — offered only where the row says canApprove / canApply (ACC-077, ACC-078). Asking for a change and withdrawing one are on the website the row shows with any of roles.request, roles.approve, roles.apply; approve roles.approve; apply roles.apply Yes/No on approve and accept; reason on reject
Staff More → Partners, Home → partners waiting The partners the login may read (Agent rows, pending first; the pending count on Home); the partner record (partner_record — PTR-083) with the profile, contacts (Call / WhatsApp), performance and notes (PTR-084 … PTR-089); approve / reject a new partner (and approve a closed one never approved) with a reason — partners.approve (PTR-095); suspend / deactivate / reactivate with a reason (partner_set_status — the partner told); pause / resume the login; review a document (partner_document_review — PTR-080); Add note (partner_note_add — PTR-088). Editing the profile and contacts and filing a document for the partner are desktop-only agents.view to see; partners.approve to approve or reject a new partner; partners.edit for the rest reason on every status change; note on a rejection; Yes/No on a note
Staff Partners → Add partner, Home → New partner, More → Add a business partner Add a business partner with the desktop form's fields; pending until approved, sales@ told with the terms; a PAN or GSTIN already used needs "Add anyway" — named only to agents.view holders (partner_staff_create — PTR-097). Commission % and Credit limit show only with their permission, otherwise 0 (PTR-030). No portal login from the phone partners.create (the function checks it, and that the login is staff); agents.commission.set / agents.credit_limit.set for the terms Yes/No with the terms
Staff More → Notifications Alerts on this phone (push-subscribe, FCM); the inbox of kept notifications — read, mark read, mark all read, the unread count on the row (AppNotification RLS, ntf_mark_read, ntf_mark_all_read, ntf_unread_count — TRV-010) any signed-in user, own device; own rows only, no staff permission —
Leader More → Notifications The same inbox and alert switch own rows only —
Leader My groups, Group The groups I lead (fld_my_groups), the manifest, check-ins (§6.5b) field.view, field.checkin for a group I lead —
Leader Live Where my group's sharing travellers are (fld_group_locations, realtime on TravellerLocation) field.view and TravelGroup.leadUserId = me —
Leader More → The travellers' guide The step-by-step guide the travellers read, for the trip type of the group I lead, in my own language and tradition (trv_guide); a trip type picker when no group is current the trip types of the groups I lead (TravelGroup.leadUserId) and of my own bookings, plus the steps for every trip — TRV-022; staff with programme.manage, groups.view or guide.religious.approve read every type —
Leader Programme Today's plan; add, edit and finish activities; post a notice (trv_upsert_activity, trv_post_notice, audience trv_group_audience → push-send) field.checkin and the group's lead —
Traveller My trip My bookings, group, leader, passengers, emergency contact, balance (trv_my_trips); SOS (calls only); the location switch (trv_set_location_sharing, then trv_report_location from the phone while on) CUSTOMER login linked to the customer (Customer.userId); own bookings only the switch says who sees it; off in one tap
Traveller My trip (a passenger on someone else's booking) The trip, programme, own passenger row and passport scan, the others by name, the emergency contact, own location switch, own documents (trv_my_trips role passenger, trv_trip_programme, trv_submit_passport, trv_set_location_sharing, trv_my_documents) — no money, no payment, no request naming the booking — TRV-001 CUSTOMER login whose customer record is a live passenger on the booking (auth_passenger_booking_ids()); no row policy widened —
Traveller My trip → Money → Receipt The receipt of a verified payment on my booking (issue-document, kind receipt, FIN-045); Open, Share my booking as customer or payer (checked in the function); a passenger on someone else's booking sees no payments and gets no receipt; no re-issue —
Traveller My trip → Flights and hotels, payments, the office Every flight with its times and the hotel in each city (trv_trip_programme — no PNR); the payments recorded on the booking and their state (RLS Payment select customer); Call / Write to us (customer_create_request) — TRV-014 own bookings only; no staff permission —
Traveller My trip → Scan passport Photograph a passport for one of my passengers → pending submission (trv_submit_passport) own booking's passenger — (staff review it)
Traveller Programme The departure's flights, hotels, activities and notices without the PNR (trv_trip_programme, realtime on GroupActivity / GroupNotice) a live booking of mine on the group —
Traveller Guide The step-by-step guide for my trip type in my language, for my tradition and gender, approved text only; Show both (trv_guide, realtime on TripGuideStep) — TRV-018 … TRV-021. Only the steps for every trip and my own live bookings' trip types; no picker; with no trip yet, the steps for every trip only — TRV-022 any signed-in user, for their own trips' types (trv_guide_readable_types()); approved, active steps —
Traveller Me Who is signed in, the guide's language and my tradition (trv_my_guide_preferences, trv_set_guide_preferences — own login only; no staff screen reads it — TRV-018, TRV-021), the location switch, notifications, what the company keeps (links to the privacy page), sign out own login —
Traveller Me → Notifications The same inbox (a notice posted to my group is in it the moment it is posted — trv_post_notice keeps it) and the alert switch own rows only —
Visitor Welcome → Create an account Create a customer account by email (customer-signup) or with a WhatsApp code (auth-login signup_verify) — a login only, no customer record until the first booking request (TRV-007, TRV-016, LC-006). A customer can also sign up with no app at all, by writing "sign up" to the business number on WhatsApp (TRV-017; the first password is set on the web page /customer/set-password, which the app opens) public sign-up; the new login is CUSTOMER and nothing more (written by the database: auth_customer_login_create / auth_signup_create_login); no staff permission privacy terms ticked
Visitor Welcome → Browse tours without signing in; Tour The departures on sale as posters (public_departures, public_groups) public — the anonymous key's allow-list (ACC-053); no sign-in —
Traveller Tours, Tour The same departures, signed in public functions —
Traveller Tour → Book this trip A booking request naming the travellers, room preference and phone (customer_create_request, type group_interest, TRV-008) — never a booking the customer's own login (portal_require_customer); no staff permission the sheet says it is a request and that the office creates the booking
Traveller Requests and quotes → Requests My requests and the office's replies (RLS CustomerRequest select customer); a question, document, change or cancellation request (customer_create_request) own login; a booking named in it must be mine —
Traveller Requests and quotes → Quotes The quotations sent to me (RLS Quotation select customer, QuotationLineItem select portal); accept or decline (customer_respond_quotation, TRV-009) own login; only my own, only sent / pending, only before validUntil Yes/No with the total on accept; a reason on decline
Traveller Requests and quotes → Payments The balance per booking (trv_my_trips); I have paid records a claim as pending (customer_submit_payment, FIN-032) own booking as customer or payer; no staff permission — finance verifies with finance.payments.verify the sheet says nothing moves money
Traveller Requests and quotes → Payments → Pay online A Razorpay order for one booking (edge function razorpay-order → online_payment_actor), Checkout on the phone, then the app watches OnlinePaymentOrder / Payment (RLS, own bookings) for the receipt razorpay-webhook records verified own booking as customer or payer; the amount ≤ Booking.balanceAmount; no staff permission the page says nothing is charged until confirmed inside Razorpay; closing it records nothing
Traveller My trip → My documents; Me → My documents The visa case and its files, the e-ticket record (no PNR), the hotel stays (no price) and the papers under my own record (trv_my_documents, TRV-011); open a file (edge function drive-file, a ten-minute signed link), including the issued e-ticket sheet on a ticket row and the issued hotel voucher on a hotel row (kinds ticket / hotel, TRV-013) own login — only what trv_my_documents() lists for me; staff with customers.view, visa.view or bookings.view may open any document through drive-file —
Traveller Me → Remind me before each activity Local programme reminders on this phone (expo-notifications; no server) own phone; the phone's notification permission —
Traveller Me → My details Read and change name, phone, date of birth, address (RLS Customer select own, customer_update_profile) own login; identity fields frozen once a visa case has started —
Traveller Me → Request account deletion What is erased and what the law keeps; DELETE typed and the password; always a request to the office, never erased on the person's own action (account_deletion_status; edge function account-delete → account_delete_self, service role only) — AUD-025 … AUD-027 the own login only (a CUSTOMER or AGENT login; staff are refused); no staff permission typed DELETE + password
Visitor Welcome → I am a business partner → Register your agency Create a pending partner account: the login unconfirmed, the User row, the AGENT role and a pending Agent row, and the confirmation email (partner-signup, PTR-082) public; the new login is AGENT and nothing more; no staff permission agreement ticked
Partner Home The agency, its status, outstanding against the credit limit, documents and what is missing, whether the login leads groups (partner_my_account) AGENT login linked by Agent.userId; own agency only —
Partner Home / More → Documents Upload a registration document to the company drive and record it (upload-partner-doc → partner_add_document, PTR-080) own agency; pending, active or suspended (a closed account is refused); never a link the sheet says who sees it
Partner Bookings, Booking The agency's bookings, passengers, payments, emergency contact and consent (RLS Booking select agency and the agency policies) own agency only —
Partner Booking → Documents The issued e-ticket sheet and hotel voucher(s) of the booking (IssuedDocument select: Booking.agentId = auth_agent_id()), open one (drive-file, kind issued; TRV-013). The partner cannot issue own agency's bookings only —
Partner Booking → Payments → Receipt The receipt of a verified payment on the agency's booking (issue-document, kind receipt: made the first time it is asked for, then handed back — FIN-045); Open, Share, WhatsApp the traveller Booking.agentId = my agency (checked in the function); no re-issue —
Partner Booking → emergency contact Record who to call (set_booking_emergency_contact, INC-005) the booking's own side: Booking.agentId = my agency —
Partner Booking → Send for approval A booking the office sent back (Sent back for correction) goes back to the team that sent it back, with a note saying what was corrected (partner_resubmit_booking, PTR-023); the database's refusal (rejected, price changed, not ready, account paused) is shown in its words own agency's NEEDS_CORRECTION booking; portal_require_active_agent() — active, not paused the sheet asks for the note (3 characters or more)
Partner New booking Create a booking on account: rate-sheet price, credit limit, seats, PENDING_OPS (partner_create_booking, PTR-020 … PTR-031); then the emergency contact and set_field_consent (source partner_portal) portal_require_active_agent() — active partners only Yes/No with travellers and the estimated total (PTR-070)
Partner Money The statement (bookings, GroupInvoice select portal payer, Payment select agency, LedgerEntry select agency statement), the invoices, the claims waiting own agency only —
Partner Money / Booking → I have paid Report a payment against one booking or one issued invoice, recorded pending (partner_submit_payment, PTR-081, FIN-032) own booking or invoice; not pending-only accounts' targets (they have none); finance verifies with finance.payments.verify the sheet says nothing moves money
Partner Money → Pay online Pick one of the agency's bookings with a balance, then the same Razorpay page (razorpay-order, online_payment_actor = partner; the receipt by razorpay-webhook) Booking.agentId = my agency; rupees only; no staff permission as above
Partner (web) Payments / Booking → Pay online, I have paid The same two actions on the web portal (/partner/payments; POST /portals/agent/payments/online-order, POST /portals/agent/payments) — PTR-093; Razorpay Checkout opens in the page as the two rows above as above
Staff — (deliberately none) Staff never start an online payment (FIN-032, DECIDED 2026-09-30): online_payment_actor never answers staff and razorpay-order refuses a staff login with 403 "Staff don't pay online. Record the customer's payment instead." Staff record the customer's payment instead (Take payment / Record payment, record_payment, pending) no permission grants it — finance.payments.record records a receipt and starts nothing at Razorpay; a staff member who is the booking's own customer or payer pays as that customer —
Partner More → Agency details Change the trading name and contact person (partner_update_profile) own agency Yes/No
Partner More → Request account deletion The same screen as the traveller's; always a request ("Business partner — the office decides"); once approved the agency's business record stays (AUD-026) — AUD-025 … AUD-027 the own login only; no staff permission typed DELETE + password
Partner More → Requests The agency's requests (RLS CustomerRequest select agency); a new one on one of its bookings (partner_create_request, PTR-051) active partner; the booking must be mine Yes/No
Partner Offers and seat sales → Hotels The beds on offer to partners and the agency's own reservations, no contract cost (partner_hotel_offers, PTR-060) AGENT login; open offers for an active partner only, own reservations for any status —
Partner Offers → Hotels → Reserve Take beds off an offer for the agency in one statement (partner_reserve_beds, PTR-040); nothing posts — the office bills portal_require_active_agent(); the offer must be unreserved or the agency's own Yes/No with beds × price (PTR-070)
Partner Offers → Seats Flight seats on offer at the published price, no cost, PNR or buyers (public_b2b_offers); the blocks the agency bought with sold / remaining from the one counter (partner_seat_inventory, PTR-041) and its sales (RLS PartnerSeatSale select agency) portal_require_active_agent() for the offers; own agency for the blocks and sales —
Partner Offers → Seats → Buy The booking wizard with the offer: partner_create_booking with p_b2b_offer_id — seats off the offer, published seat price (infants 0), credit limit, PENDING_OPS, one transaction (PTR-020, PTR-030, PTR-031) portal_require_active_agent(); an offer with no buyer or bought by this agency Yes/No with travellers and the total
Partner Offers → Seats → Sell seats Record a resale from a block the agency bought against the one seat counter under a row lock (partner_sell_seats, PTR-041) portal_require_active_agent(); B2BFlightOffer.buyerAgentId = my agency Yes/No with buyer, price and total
Partner Offers → My holds The beds reserved and the seat blocks held, with a countdown to check-in / departure (the same two reads). No release and no expiry: no partner function releases a hold, and PTR-042 is open own agency only —
Partner More → Groups I lead When the same login holds TOUR_LEADER and leads a departure (PTR-004): the leader's screens (§3 rows above) field.view, field.checkin and TravelGroup.leadUserId = me; the login stays a partner (auth_is_staff() is false) —
Staff Tab bar At most five buttons: Home and More, and between them Bookings, Groups, Operations, Approvals in that order of priority; Approvals moves to a Home row when all four apply; Dashboard is opened from Home (UX-024) each button its screen's permission: bookings.view; groups.view; Operations any inventory.*, hotels.*, food.* or suppliers.* permission, visa.view, tickets.view, or a seat-release right (inventory.release.*, finance.create); Approvals one of the nine inbox rights —
Staff Operations (tab) The week's deadlines on top; tiles for Airline blocks, Hotels, Food, Ground, Visa, Tickets with one line each; rows for Deadlines, Holds, Seat releases, Suppliers, Partner offers (UX-024). Numbers from app_operations_summary() and badge_counts the tab: as the Tab bar row; each tile and row its screen's permission (below) — with no tile only the rows; app_operations_summary answers each section only with its permission (inventory.view; releases also tickets.view / finance.view; deadlines also tickets.view; hotels.view; food.view) and refuses a login with none of inventory.view, hotels.view, food.view, tickets.view, finance.view; the deadline count is the unacknowledged rows only —
Staff Operations → Deadlines The radar in AIR §21's buckets, 7 / 14 / 30 days (dash_deadline_radar); the alerts raised (InventoryDeadlineAlert RLS) with who acknowledged — INT-120 inventory.view or tickets.view — what dash_deadline_radar and the InventoryDeadlineAlert row policy answer (the website's page asks for inventory.view) —
Staff Operations → Deadlines → Acknowledge acknowledge_inventory_deadline_alert — who and when recorded with the note; a second tap refused — AIR §22 inventory.view (the function takes inventory.view, tickets.view, inventory.release.request or inventory.holds.manage) the note: what is being done
Staff Operations → Deadlines → Run the ladder fire_inventory_deadline_alerts then escalate_inventory_deadline_alerts; each rung once — AIR §22 inventory.edit, as on the website (the functions also take inventory.release.request or tickets.edit) Yes/No
Staff Operations → Holds Active / Converted / Released / Expired / All (InventoryHold RLS, with the partner, customer, group, block, FIT, hotel and people's names) — INV-011 inventory.view, as /inventory/holds —
Staff Operations → Holds → Hold seats Airline block, FIT or hotel rooms; the position (inventory_resource_position); how many, 12–72 hours, for a name / partner / group, why → create_inventory_hold (refused above what is free — INV-012) inventory.holds.manage the form; the database's refusal shown
Staff Operations → Holds → Extend / Convert / Release extend_inventory_hold (48 hours more, capped; refused on a hold already past its end and for a new end not in the future — INV-012, 20261008235000; the phone hides Extend there), convert_inventory_hold (to the booking the hold names), release_inventory_hold; a second tap refused inventory.holds.manage reason
Staff Operations → Hotels The blocks by city, a block, the departures holding its rooms (HotelInventory, GroupHotelAssignment, service_cities RLS) hotels.view —
Staff Operations → Hotels → New hotel block The row and its purchase voucher in one call (create_hotel_lease → create_hotel_inventory + post_hotel_purchase_from_hotel; pending — FIN-030, FIN-032) hotels.create the card says what will be posted
Staff Operations → Hotels → Block → Edit Name, city, stars, distance, room type, beds per room, threshold, notes — never the cost fields (RLS hotel_perm_update) hotels.edit —
Staff More → Suppliers, Operations → Suppliers The list, a supplier, what they sell us, the ledger and balance (Supplier, SupplierTransaction, inventory RLS) inventory.view or any right Supplier select staff reads (no suppliers.view exists — §8 item 5); the ledger rows suppliers.edit, inventory.view or finance.view —
Staff Suppliers → Add supplier (button and +) / Edit / Retire / Restore The desktop form's fields; a duplicate is refused (RLS supplier_perm_insert, supplier_perm_update) suppliers.create; suppliers.edit —
Staff Operations → Hotels → Block → Assign to a departure Rooms of the block to an open departure inside its dates: rooms, beds, check-in and check-out (the shared nights offered), price per night → assign_hotel_rooms, the stay and its consumption voucher in one transaction, pending finance (FIN-030; nights checked by the database — INV-031, INV-032). The website's POST /hotels/assignments (#559 step 5) hotels.edit (checked in the function); the departure picker reads TravelGroup under RLS the sheet names rooms × nights × rate
Staff Operations → Hotels → Block → a departure → Open / Place travellers / Take these rooms back Opens the group; puts ticked travellers into this hotel (place_travellers, PAX-035); gives the rooms back and reverses the stay's voucher (unassign_hotel_rooms, the website's DELETE /hotels/assignments/:id). Changing a stay's rooms, rate or dates is not on the phone (it re-posts from the browser — FIN-030 "Not covered yet") groups.view; bookings.edit; hotels.edit Yes/No on take back, naming the rooms and travellers
Staff Operations → Hotels → Block → Who is staying Each traveller in the hotel by departure: name, booking, room or share label, dates — no passport, date of birth or phone (GroupHotelAssignment, BookingPassengerHotel, BookingPassenger, Booking RLS — the reads of GET /hotels/:id/passengers) hotels.view and bookings.view (the stays' row policy bph_select) —
Staff Operations → Hotels → Block → Offered to partners; Operations → Partner offers → Hotel beds The beds offered to partners: beds left, price per bed, dates, open / reserved / closed (B2BHotelOffer RLS). Read only — offering beds has no screen on the website either (INV-016 "Not covered yet") hotels.view and agents.view (what hotels_screen asks before it returns them) —
Staff Suppliers → supplier → Seats to pay for The supplier's live airline blocks and FITs; each opens its screen, where Payments to the airline records a payment through record_airline_block_payment (FIN-044) — the function the website's supplier screen uses for a payment against a block or FIT. Any other supplier payment, a bill and TDS stay on the website (FIN-030 "Not covered yet") inventory.view to list and open; recording inventory.block_payments.record as Record payment
Staff Operations → Partner offers The seat offers to partners (B2BFlightOffer with its block and buyer, RLS — the website's /inventory/b2b-flights, offers carved for a departure left out): Open / Closed / Cancelled / All, a search inventory.view (the row also shows for hotels.view with agents.view, for the beds) —
Staff Operations → Partner offers → Offer seats A live block, seats, price per seat, currency, notes → app_open_b2b_flight_offer: held seats and releases waiting are not offered (INV-011, INV-012), then the website's b2b_transfer_seats; audited; one offer per request id (INV-016) inventory.create (checked in the function) —
Staff Operations → Partner offers → Close app_close_b2b_flight_offer: partners can no longer buy from it; a sold, departure or cancelled offer refused (PTR-041, INV-014); audited with the reason (INV-016) inventory.edit (checked in the function) reason
Staff Operations → Airline blocks The quota blocks and the FIT seats as cards, Upcoming / All / Archived, search (AirlineQuotaBlock, FITInventory, Airline under RLS); a block: legs, the seat position with holds and pending releases (inventory_resource_position), the contract and what the airline is paid (AIR §15), deadlines (AIR §21), the departures using it (GroupFlight), its releases (SeatRelease, tap for Seat releases) and the airline's penalty bands (AirlineReleasePolicyBand) — read-only inventory.view —
Staff Operations → Airline blocks → New block Starts as a draft (AIR §36). Save draft keeps the form and posts nothing (create_quota_block_draft / update_quota_block_draft). Review and submit runs the desktop's checks (unique PNR, leg chronology, FOC ≤ seats, a rate on a foreign contract) and Yes calls submit_quota_block: the row and its purchase voucher in one transaction (create_quota_block + post_quota_block_purchase_from_block, FIN-030) — Dr Stock-in-Hand for the paid seats, Dr GST Input Credit, Cr the supplier's ledger, pending finance (FIN-032) — with a deposit paid now recorded through record_airline_block_payment (initial_payment, FIN-044); a refusal leaves the draft with the reason save a new draft and submit inventory.create; the deposit inventory.block_payments.record (the card shows only then) the review sheet shows the voucher and the deposit
Staff Operations → Airline blocks → Drafts → Draft What the submit would post and what is still missing (quota_block_draft_preview); Edit, Submit block, Discard draft (discard_quota_block_draft, a reason, audited) list, edit, discard inventory.create or inventory.edit; submit inventory.create Yes/No on submit; reason on discard
Staff Operations → Airline blocks → Block → Edit Flight, legs, PNR, supplier, payment due and free-release deadlines, notes — never the cost fields (a re-cost is the desktop's adjustment voucher); a table update under RLS airlinequotablock_perm_update inventory.edit —
Staff Operations → Airline blocks → Block → Archive / Restore archive_airline_block (a reason; refused while seats are allocated) and restore_airline_block (AIR §26) inventory.edit reason on archive
Staff Operations → Airline blocks → Block → Payments to the airline Paid so far, still owed and the payments with their voucher status (airline_block_payment_summary); the ledgers money may leave from (airline_block_paid_from_accounts) inventory.view or finance.view to read; the list inventory.block_payments.record or finance.view —
Staff Operations → Airline blocks → Block → Record payment Amount in the contract currency (outstanding prefilled), paid from, method, reference, date, note → record_airline_block_payment: Dr the supplier's SUP- ledger / Cr the account, the ledger mirror and the supplier transaction, pending finance (FIN-044, FIN-032); over the outstanding, a future date, an archived or cancelled block refused; a TDS supplier refused unless the caller holds finance.tds.deduct (FIN-021) inventory.block_payments.record Yes/No with the two lines
Staff Operations → Airline blocks → Block → Record refund Amount received (≤ paid so far), received into, method, reference, date, note → record_airline_block_refund: Dr the account / Cr the supplier's SUP- ledger, the supplier_refund mirror and the supplier's refund row, pending finance (FIN-044, FIN-032); above what was paid, a future date, an archived block refused; no TDS inventory.block_payments.record Yes/No with the two lines
Staff Operations → Airline blocks → FIT seats → FIT Read-only: flight, seats, contract, deadlines, notes (FITInventory RLS) — plus Payments to the airline with Record payment / Record refund as for a block (p_kind: 'fit') inventory.view; recording inventory.block_payments.record as above
Staff Operations → Airline blocks → Block → Third-party sales The seats sold on to other agencies — buyer, seats, price, status, invoice, names given, the latest cancellation filed / approved / rejected (block_third_party_sales) inventory.view or finance.view —
Staff Operations → Airline blocks → Block → Sell seats to another agency Buyer from the party ledgers (inv_party_accounts) or typed, seats ≤ open, margin per seat (GST-inclusive), currency, GST, a reason below cost, notes → sell_block_seats_to_third_party: the offer, the revenue and COGS vouchers (pending — FIN-032) and the buyer's invoice in one transaction (INV-014) inventory.edit Yes/No with the buyer's total, commission and GST
Staff Operations → Airline blocks → Block → sale → Names The buyer's traveller names, one per seat, replaced in one call (b2b_set_manifest, INV-015) inventory.edit —
Staff Operations → Airline blocks → Block → sale → Cancel this sale Seats (default all), refund to the buyer (default the sale value less the charge), the buyer's charge, a reason, optionally the airline's side (charge, refund, approval reference, notes) → file_b2b_cancellation: the seats move and one pending_approval row is written in one transaction; "Filed — finance decides" (INV-014). The decision is finance's (decide_b2b_cancellation, finance.cancellations.approve_b2b), never the filer's (FIN-032); a filing already pending is refused inventory.b2b_cancellations.file or finance.create Yes/No with the split and where the seats go
Staff Operations → Seat releases The releases (SeatRelease under RLS, with the block labelled "code · PNR · route"), Waiting approval / Approved / Completed / All; each shows seats, type, requested by, penalty and expected refund, the status, and "Decided by self" when selfDecided (AIR §16–§18, §30). The row shows with inventory.view or any release right read inventory.view, tickets.view or finance.view (RLS); the Operations row inventory.view, inventory.release.request, .approve, .approve_large, .file or finance.create —
Staff Seat releases → Request a release (also Airline blocks → Block → Release seats, preselected) A live block picked by "code · PNR · route", seats, type and deadline (both optional — auto from the preview), a reason; the live preview (preview_seat_release: seats free, penalty, expected refund, FOC impact, approval level) → request_seat_release inventory.release.request Yes/No naming who decides (manager, CEO/GM above the seat limit, or own under approve_own)
Staff Seat releases → Approve / Reject approve_seat_release (optional remarks) / reject_seat_release (a reason). The requester's own only with inventory.release.approve_own — the confirm says it will be marked "decided by self"; above the ACC-030 seat limit only with inventory.release.approve_large; otherwise not shown and refused in the database's words (ACC-020) inventory.release.approve or inventory.release.approve_large Yes/No
Staff Seat releases → Withdraw cancel_seat_release (a reason): a waiting request by its requester or an approver; an approved one before filing inventory.release.request or inventory.release.approve Yes/No
Staff Seat releases → File An approved release: the airline reference, the actual penalty and refund (prefilled from the expected; the variance is kept, AIR §20) → complete_seat_release; when money moves an airline cancellation filing opens pending_refund and finance settles the refund (CXL-030) inventory.release.file or finance.create Yes/No
Staff Operations → Food The meal contracts (FoodInventory RLS); one contract and the departures on it (GroupFoodAssignment RLS) food.view —
Staff Operations → Food → New contract Write the contract and post its purchase voucher and the caterer's bill from the database (create_food_inventory → inv_post_food_purchase_as) — FIN-030, FIN-034 food.create the sheet says what the caterer will be owed
Staff Operations → Food → Edit Change the contract (update_food_inventory); quantity refused, a total under the allocation refused — INV-042, INV-012 food.edit —
Staff Operations → Ground transfers The transfers (GroundTransferInventory RLS); one transfer and the departures on it inventory.view —
Staff Operations → Ground transfers → New / Edit Direct table writes under the row policies (groundtransferinventory_perm_insert / _update); nothing posts on creation inventory.create / inventory.edit —
Staff Group → Services The rooms, meal plans and transfers on a departure with the capacity each uses (assignment tables' RLS) groups.edit to open —
Staff Group → Services → Assign / Remove rooms assign_hotel_rooms (derives the consumption voucher, calls create_hotel_assignment), unassign_hotel_rooms hotels.edit the sheet names rooms × nights × rate; Remove confirms
Staff Group → Services → Assign / Remove a meal plan assign_food_plan (fed / billed from the manifest or a typed head-count, INV-040, INV-042), unassign_food_plan food.edit the sheet names fed, billed and meal-days drawn; Remove confirms
Staff Group → Services → Assign / Remove seats assign_ground_transfer (seats + the expense voucher in one transaction), unassign_ground_transfer inventory.edit the sheet names seats × price; Remove confirms
Staff Group (header and Operations) The departure's phase (Closed — INV-006), dates and seats booked (TravelGroup RLS); Operations rows for Flights, Rooming, Readiness, a Ticket sheet per flight, Services and Notify group, each only with its own right (#559 step 3) groups.view for the header and Operations; each row its screen's permission (rows below) —
Staff Group → Flights The linked blocks and FIT tickets with PNR, date, seats free and how many travellers are on each (GroupFlight, AirlineQuotaBlock, FITInventory RLS; the count BookingPassengerFlight RLS) — INV-033 groups.view (a block's details need inventory.view; the count bookings.view — left out without it) —
Staff Group → Flights → Link a flight / Add or Change PNR / Unlink app_link_group_flight (a block or FIT within a day of the departure — INV-032; one departure per block or FIT; a block with fewer free seats than the departure needs refused — INV-012), app_set_group_flight_pnr, app_unlink_group_flight (refused while a traveller is on the flight); each audited — INV-033. The website's Flights tab writes the table from the browser instead (INV-033 "Not covered yet") groups.edit (checked in each function) Yes/No on link and unlink
Staff Group → Flights → a flight → Travellers and seats Who is on the flight, with PNR, ticket and seat (BookingPassengerFlight, BookingPassenger RLS); set or clear a traveller's seat number (a table update under bpf_update — the website's PATCH /sales/bookings/:b/passengers/:p/flights/:a). Putting a traveller on a flight stays on the website (it posts the seat's cost, FIN-035) groups.view and bookings.view to see; bookings.edit to set —
Staff Group → Flights → a flight → Ticket sheet; Group → Ticket sheet Opens that flight's ticket sheet (ticketing_flight_sheet, row above) tickets.view —
Staff Group → Rooming Each hotel stay's rooms and who is in each, as the website's rooming-list export groups them (GroupHotelAssignment, BookingPassengerHotel, BookingPassenger RLS) — INV-030; set or clear a traveller's room number (a table update under bph_update — the website's PATCH /sales/bookings/:b/passengers/:p/hotels/:row) groups.view and bookings.view to see; bookings.edit to set —
Staff Group → Flights → Share flight manifest; Group → Rooming → Share rooming list Plain text to the phone's share sheet: names, rooms, seats, PNRs and ticket numbers — never a passport number or a date of birth. Read as the website's exports read groups.view and bookings.view —
Staff Group → Notify group (the send button in the header, or the Operations row) A notice in every traveller's app and a push — the same sheet and functions as Programme → Post a notice (trv_post_notice, trv_group_audience → push-send) — TRV-004 groups.edit (trv_can_write_group) —
Staff Group → Readiness Opens Readiness (row above) as Readiness —

6.6 Approvals (/approvals) + Corrections (/corrections)

Section Action Permission
Route /approvals View queue approvals.view
Route /corrections View queue approvals.view (currently bookings.view — drift; see §8)
Row Approve booking — ops (ops_decide_booking, approver ≠ creator) approvals.approve
Row Reject booking — finance (finance_decide_booking, approver ≠ creator) approvals.approve or finance.bookings.reject_finance — the route checks the same pair as the DB (20261008140000)
Row Send back for correction approvals.approve (finance's send-back: or finance.bookings.reject_finance)
Row Log communication approvals.view
Row Assign correction owner (assign_booking_correction, NEEDS_CORRECTION or REJECTED only) approvals.approve — checked in the DB function since 20261008140000; the browser's row write needed bookings.edit
Row Resubmit corrected bookings.edit (a partner resubmits its own booking from the portal, PTR-023)

6.7 Visa (/visa, /visa/:id) + Tickets (/tickets, /tickets/:id)

Section Action Permission
Route /visa View pipeline visa.view
Route /visa/:id View case visa.view
Route /visa/groups/:id View visa group visa.groups.view
Route /sales/visa View public intake queue visa.intake.view
Route /visa/apply Public application form (none — public)
Header New visa case visa.create
Header Upload document visa.edit
Header Sync visa cases visa.edit
Row Bulk status change visa.edit
Row Generate invoice — bill the selected finished visas to the agent who processed them (FIN-043) finance.create (DB bill_visa_cases)
Row Per-row status visa.edit
Docs dialog Delete visa doc visa.edit
Group detail Edit visa group code visa.groups.edit
Group detail Set status override visa.groups.edit
Group detail Close visa group visa.groups.edit
Intake queue Convert to visa case visa.intake.convert
Intake queue Reject / change intake status (reason required) visa.intake.convert
Row / detail Status change — only VISA-003 transitions, reason required, changedBy recorded visa.edit (DB change_visa_status)
Docs dialog Remove visa doc — soft delete with reason visa.edit (DB delete_visa_document)
Route /tickets View the departure list, open a departure's sheet tickets.view (DB ticketing_departures, ticketing_flight_sheet)
Route /tickets/:id View detail tickets.view
Sheet Enter / paste ticket numbers and save a departure — booking confirmed by finance or an approved exception, real e-ticket number, no duplicates, reason, all or nothing tickets.approve (DB issue_tickets)
Row Ask for an exception (reason) tickets.edit (DB request_ticket_exception)
Row Approve / refuse an exception — not the person who asked tickets.approve (DB decide_ticket_exception)
Detail Upload ticket doc tickets.edit

Retired 25 Sep 2026 (20260925100000): Sync tickets, Print report on the old queue, Bulk update names and Update passenger name — tickets are created from the seat and take the passenger's name. | Visa groups | Create / rename / retire a visa group (VisaGroup) | visa.groups.edit; read with visa.groups.view or visa.view (DB: VisaGroup RLS) | | Intake | Read an intake request and its attachments | visa.intake.view (DB: VisaIntakeRequest / VisaIntakeAttachment RLS) |

6.8 Finance (/finance)

Section Action Permission
Route View module finance.view
Accounts Create GL account finance.edit
Accounts Edit GL account finance.edit
Accounts Delete GL account finance.edit
Accounts View ledger drill-down finance.view
Accounts Read an account's balance (GET /accounts/:id/balance → finance_account_balance; the figure the insufficient-balance guard decides on, FIN-033) finance.view (DB finance_account_balance)
Payments Record payment (always saved pending; createdBy from the session) finance.payments.record — only this permission since F1 (DB record_payment, Payment insert policy)
Payments Record one receipt against several bookings (one atomic call — POST /finance/payments/batch → record_payments_batch) finance.payments.record
Accounts Set or restate an opening balance (POST /finance/accounts/:id/opening-balance → set_account_opening_balance; posts a pending voucher and reverses the previous one) finance.create (DB: finance.create or finance.edit)
Settings Move a document counter forward (set_document_counter, reason required) finance.config.edit
Payments Verify payment — posts the receipt voucher approved in the same transaction; recorder cannot verify (FIN-032) finance.payments.verify (DB verify_payment)
Payments Reject payment (reason required; the Reject button shows for either permission) finance.payments.reject or finance.payments.verify (DB verify_payment)
Payments Request refund — cap and original account shown (PRC-030) finance.payments.refund (DB refund_payment)
Approvals Approve / reject refund request (requester cannot decide; posts refund voucher RFV-nnnnn) finance.refunds.approve, plus finance.approvals.high_value above the ACC-030 limit (DB approve_refund + fin_check_approval_limit)
Settings Approval limits by amount (ApprovalLimit) — read by any staff user, changed with finance.config.edit
Payments Mark booking fully paid Removed 2026-09-18 (route deleted)
Unposted entries See the unposted-accounting-entries card (GET /finance/posting-failures) finance.view, bookings.view or approvals.view (DB PostingFailure select)
Unposted entries Retry the posting (POST /finance/posting-failures/:id/retry → retry_posting_failure; useConfirm with a reason; re-posts the recorded lines and resolves the row on success) finance.create (DB retry_posting_failure)
Unposted entries Mark one resolved after posting it by hand (useConfirm with a reason; the record itself can never be edited or deleted) finance.create (DB PostingFailure resolve)
Unposted entries Record a posting failure Any staff user (DB PostingFailure insert → is_staff_user()) — deliberately: the row is written on the failure path of someone else's action, so gating it would make the miss silent again (FIN-030)
Vouchers Post a customer receipt against a booking or invoice (sent to /finance/payments; the composer offers only the voucher types the user may post) finance.payments.record
Vouchers Post a customer receipt on account (/finance/vouchers/customer-on-account-receipt) finance.edit
Vouchers Post a supplier refund or business partner receipt, payment, contra, debit / credit note, settlement finance.create
Transactions Record Payment, and Pay on a booking under Top Outstanding finance.payments.record
Transactions Print a verified receipt (shows Received from: the booking's partner with partner code, else payer or customer with customer code) finance.view
Journal Create manual journal (the phone: DB create_manual_journal, §6.5d) finance.create
Journal Reverse journal — the Reverse action turns away the voucher's maker (screen-level); in the database the reversal waits until someone who made neither voucher approves it (ACC-020) finance.journals.reverse (own voucher on the Reverse action: finance.journals.reverse_own)
Journal Approve / reject voucher (creator cannot decide; nor can the maker of the voucher a reversal reverses — ACC-020). Above ₹25,000, every voucher a person writes — journals, contras, on-account receipts, reversals on custom lines — needs finance.approvals.high_value (ACC-030) finance.journals.approve / finance.journals.reject (DB approve_journal_entries, trigger "JournalEntry_limit_guard")
Journal Approve all pending vouchers (confirmation; own vouchers skipped) finance.journals.bulk_approve
Approvals Approve booking (finance) — finance_decide_booking, approver ≠ creator approvals.approve or finance.bookings.approve_finance (route and DB alike since 20261008140000)
Approvals Reject booking (finance) approvals.approve or finance.bookings.reject_finance (route and DB alike)
Cancellations List B2B cancellations finance.view
Cancellations Approve / reject B2B cancellation (filer cannot decide; reject restores seats in the same transaction) finance.cancellations.approve_b2b (DB decide_b2b_cancellation)
Cancellations Approve / reject airline cancellation refund (bank refund or supplier credit note; filer cannot approve — CXL-030/031) finance.cancellations.approve_airline (DB-enforced in settle_airline_cancellation / reject_airline_cancellation)
Allocate Allocate agent receipt finance.payments.record
Reports View trial balance, P&L, balance sheet, day book, aging finance.view
Reports Export any reports.export
Settings Create period finance.periods.create or finance.edit (RLS)
Settings Lock / unlock / close period (DB guard stamps who; closed periods cannot reopen) finance.periods.lock / finance.periods.unlock / finance.periods.close
Settings Close financial year finance.years.close (DB close_financial_year)
Settings Run FX period-end revaluation finance.periods.close
Settings Rebuild ledger / quota ledger / manual ledger entry finance.ledger.rebuild
Settings Nuclear ledger reset (wipe all) Removed 2026-09-18 (finance.ledger.reset route answers 410)
Accounts Force-delete a GL account with its vouchers Removed 2026-09-18 (accounts with activity are deactivated)
Reports Trial balance, P&L, balance sheet, cash flow, day book (aggregated in DB by entryDate) finance.view (DB finance_account_totals, finance_day_book_page)
Reports GST summary finance.reports.gst_summary.view
Settings Save finance config finance.edit
Settings Stock adjustment finance.edit
TDS View TDS rate master / deduction ledger finance.tds.view
TDS Export 26Q CSV finance.tds.export
Journal Attach a document to a voucher (JournalAttachment) finance.create or finance.edit; read with finance.view (DB: JournalAttachment RLS)
Journal Save / use a recurring journal template (JournalTemplate) finance.create or finance.edit; read with finance.view (DB: JournalTemplate RLS)
Settings Store an FX rate snapshot (exchange_rate_snapshots) finance.edit or finance.create; read with finance.view
Settings → Exchange Rate Management See the rates in force (GET /finance/fx-rates) finance.view
Settings → Exchange Rate Management Add / update a rate — today in IST by default, never after today, a reason for an earlier date (DB set_exchange_rate, audited; FIN-046) finance.rates.manage (checked in the function; the table refuses direct writes from any browser session)
Settings → Exchange Rate Management Refresh Rates from the live feed (POST /finance/fx-rates/refresh → set_exchange_rate per currency; never replaces a manual rate of the same day) finance.rates.manage (was finance.edit until 20261006100000)

6.9 Partners (admin) (/agents)

Section Action Permission
Route View partners agents.view
Header Add partner agents.create
Add / Edit dialog Commission % and Credit limit (disabled, with a note, without the permission; the database refuses a change — PTR-030) agents.commission.set; agents.credit_limit.set
Row Edit partner agents.edit
Row View ledger agents.view
Ledger Allocate receipt finance.payments.record
Ledger Print statement reports.export
Row Set/reset access key agents.edit
Row Deactivate / reactivate agents.edit
Row Approve / reject a new partner (the ⋯ menu on a pending partner, or a closed one never approved) — POST /agents/:id/status — PTR-095 partners.approve
Row Delete partner agents.delete
Row Open the partner profile (click the card or row; /partners/:id, GET /partners/:id/record → partner_record) — PTR-083, PTR-091. The row's other actions sit in one ⋯ menu with the gates above — PTR-092 agents.view
Record Approve / reject a registration, or approve a closed partner never approved (action bar) — POST /agents/:id/status → partner_set_status with a reason — PTR-001, PTR-095 partners.approve
Record Suspend, lift, deactivate, reactivate (More menu) — POST /agents/:id/status with a reason; Edit terms (commission, credit) and Edit profile (More menu, About tab) — PTR-001, PTR-083, PTR-091 partners.edit; changing the commission or credit limit also needs agents.commission.set / agents.credit_limit.set (refused by the database otherwise — PTR-030)
Record → action bar New booking — opens the booking wizard with this partner chosen (/sales/bookings/new?agentId=<id>); shown for an active partner — PTR-091 bookings.create
Record → More Access key (opens the list's access-key dialog, /agents?accessKey=<id>) — PTR-091 partners.edit
Record → More Delete — typed confirmation, DELETE /agents/:id, back to the list — PTR-091 agents.delete
Record → More Pause / resume the partner's login with a reason (PATCH /users/:userId → admin_pause_user / admin_resume_user) — ACC-070 partners.edit (or admin.users.edit)
Record → Documents Review a document: OK, or reject with a note (POST /partners/:id/documents/:docId/review → partner_document_review) — PTR-080 partners.edit
Record → Timeline / Bookings / Payments & ledger / Travellers Read the merged timeline (audit rows as action, field names and actor — AUD-004), the bookings and requests, the money against the credit limit, and the travellers — PTR-091 agents.view (inside partner_record)
Record → About / Timeline sidebar Read the profile (business, address, registrations, office relationship with internal notes), the contacts and the performance numbers — PTR-084, PTR-085, PTR-089 agents.view (inside partner_record; PartnerProfile / PartnerContact row security)
Record → About / More Edit profile: only the changed fields (PATCH /partners/:id/profile → partner_update_profile_admin; the relationship manager must be an active staff login) — PTR-084 partners.edit
Record → About → Contacts Add, change, remove (with a reason, soft) a contact (POST /partners/:id/contacts → partner_contact_save; DELETE /partners/:id/contacts/:contactId → partner_contact_remove); Call / WhatsApp (PTY-008) partners.edit (Call / WhatsApp: agents.view)
Record → Documents File a document on the partner's behalf, optional valid-until date (upload-partner-doc with agentId → partner_add_document_for); remove a document record with a reason, the file stays on the drive (DELETE /partners/:id/documents/:docId → partner_remove_document) — PTR-087 partners.edit (checked by the edge function and the database function)
Record → Notes / Timeline composer Read the notes; Add note or Post from the Timeline (kind, text, optional follow-up date) — append-only, never edited or deleted (POST /partners/:id/notes → partner_note_add) — PTR-088 agents.view to read; partners.edit to add
Record → Access The login: active / paused, authenticator, last sign-in, sign-ins in 30 days, failed this week; sessions (device, browser, IP); devices with push alerts; the last sign-ins agents.view (inside partner_record)
List City, relationship manager and tier columns and filters, and the person each partner is named by (partner_list_extras) — PTR-090, PTR-092 agents.view

6.10 Customer portal (/customer/**) — role-scoped

Not enforced against staff matrix. Access gated by allowedRoles={['customer']}, RLS ownership policies and the portal functions in §4.5. Every state-changing action asks Yes/No first (UX-001).

Action Enforcement
View own bookings, payments, visa cases, invoices, statement RLS ownership (auth_customer_ids())
Download own invoice (/sales/bookings/:id/invoice) RLS — 404 for other bookings; portal callers never create Invoice rows
Browse / apply for open groups public_groups(); apply = customer_create_request (confirm)
Pay Now (/portals/customer/payments) customer_submit_payment (confirm with amount summary)
Accept / decline quotation customer_respond_quotation (confirm; decline asks for a reason)
Create / respond to request (/portals/customer/requests/new, /portals/customer/requests/:id/respond) customer_create_request / customer_respond_request (confirm)
Edit profile customer_update_profile (confirm)
Request account deletion (My Profile, the card at the foot) — AUD-025 … AUD-027 edge function account-delete (password checked) → account_delete_self (service role only; files a request, never erases); account_deletion_status for the own view (typed DELETE + password)
Open own visa documents and request attachments drive-file — own documents (trv_my_documents) or own uploads (ACC-074)
Manage own sessions UserSession own rows policy
Set the first password from a WhatsApp sign-up link (/customer/set-password?t=…, public page) — TRV-017 no session needed: auth-login set_password_with_token → auth_password_set_token_use (service role only; a hashed one-time link, 30 minutes, only for an active customer-only login); no staff permission
Pay a booking's balance from a WhatsApp payment link (/pay/:token, public page) — COMM-034 no session needed: razorpay-order { payToken } → whatsapp_pay_link_use (service role only; a hashed link, 30 minutes, one use, bound to the booking, the amount and the customer or payer whose number asked for it); the order is the customer's (actorKind customer), never staff's; ten openings an hour per connection
The WhatsApp menu (writing to the business number) — COMM-030 … COMM-036 no login: the number WhatsApp delivered the message from is the identity. whatsapp-webhook (signature-checked) calls service-role-only functions: whatsapp_menu_bookings shows only the live bookings where the number is the customer's, the payer's or a live traveller's own; a number on more than one customer's bookings is shown nothing; a partner's number is kept out

6.11 Partner portal (/partner/**) — role-scoped

Not enforced against staff matrix. Access gated by allowedRoles={['agent']}, RLS agency policies and the portal functions in §4.5. Rules: docs/rules/11-partners.md. Every state-changing action asks Yes/No first (UX-001). Pending or suspended partners can sign in and read but cannot transact — except that a suspended partner may still pay what it owes (PTR-081).

Action Enforcement
Sign up (/auth/partner-signup) partner_register — pending until approved
Dashboard, own bookings / customers / quotations / statement RLS agency policies; customer lists mask passport numbers (AUD-020)
Create booking (/portals/agent/bookings) partner_create_booking (confirm) — rate sheet price, credit limit, capacity, on_account, the submit readiness check (PTR-022)
Add passengers (/portals/agent/bookings/:id/passengers) partner_add_passengers (confirm)
Send a sent-back booking for approval again (/portals/agent/bookings/:id/resubmit) — PTR-023 partner_resubmit_booking (confirm with a note) — own NEEDS_CORRECTION booking only, active and not paused; resubmit_booking accepts the owning partner in place of bookings.edit
Who is family on my booking; mark not family; set a guardian (booking detail, and after the booking is made) — PAX-036, PAX-021 booking_families, family_save, travellers_set_family_status, traveller_set_guardian — the partner's own travellers only (the function checks ownership); other partners' and the office's travellers are unnamed
Flight offers (/partner/flights, /portals/agent/b2b-flights) and booking seats — flights only, no travel groups (PTR-094) public_b2b_offers; booking via partner_create_booking with the offer (confirm)
Hotel offers / reserve beds (/portals/agent/hotel-offers/:id/reserve) partner_hotel_offers, partner_reserve_beds (confirm)
Seat inventory / sell seats (/portals/agent/seat-sales) partner_seat_inventory, partner_sell_seats (confirm)
Create / edit draft / issue invoice (/portals/agent/invoices) partner_upsert_invoice (confirm)
Requests (/portals/agent/requests), optionally naming a departure (the enquiry that was on Flights — PTR-094) partner_create_request (confirm; the departure must be one public_groups() or the agency's bookings show)
Payments page (/partner/payments, sidebar Payments; GET /portals/agent/payments) — PTR-093 allowedRoles={['agent']}; the reads are the agency's rows under RLS; partner_my_account
I have paid (/partner/payments, and the booking detail on /partner/bookings; POST /portals/agent/payments) — PTR-081, PTR-093 partner_submit_payment (confirm) — own booking or issued invoice only, recorded pending; finance verifies with finance.payments.verify
Pay online (/partner/payments, and the booking detail; POST /portals/agent/payments/online-order) — PTR-093 edge function razorpay-order → online_payment_actor = partner (confirm); rupees only, ≤ Booking.balanceAmount; the receipt is recorded by razorpay-webhook
Edit name / contact person (/me → PATCH /portals/agent/profile) partner_update_profile (confirm)
Request account deletion (/me, the card at the foot) — AUD-025 … AUD-027 edge function account-delete (password checked) → account_delete_self (service role only; files a request, never erases); once approved the agency's business record stays (typed DELETE + password)
Edit own address and website (/me → PATCH /me/partner-profile) — PTR-086 partner_update_my_profile (confirm; allow-list; agency from the session)
Add, change, remove own contacts (/me → POST /me/partner-contacts, DELETE /me/partner-contacts/:id) — PTR-085, PTR-086 partner_contact_save / partner_contact_remove (confirm; own agency only; removal needs a reason)
Own profile and documents (/me: GET /me/profile → profile_me, GET /me/documents → AgentDocument select agency; upload through upload-partner-doc → partner_add_document) — ACC-071 own agency only; a paused login reads (ACC-070)
Manage own sessions UserSession own rows policy

6.12 Inventory (/inventory/**)

Route Action Permission
/inventory/quota View blocks inventory.view
Create/edit/delete block inventory.create / inventory.edit / inventory.delete
Save draft / correct / discard a draft block (POST /inventory/quota-blocks draft: true, PATCH/DELETE /inventory/quota-blocks/drafts/:id; Drafts list GET /inventory/quota-blocks/drafts; the preview GET …/drafts/:id/preview) — AIR §36. A draft posts nothing and cannot be used new draft inventory.create; correct, discard, list, preview inventory.create or inventory.edit (DB create_quota_block_draft, update_quota_block_draft, discard_quota_block_draft, quota_block_draft_preview; RLS AirlineQuotaBlockDraft select ticketing)
Submit block — creates it, posts its purchase voucher and records the deposit in one transaction (POST /inventory/quota-blocks/drafts/:id/submit) — AIR §36 inventory.create; the deposit also inventory.block_payments.record (DB submit_quota_block)
Write off seats bought and never sold (FIN-037) inventory.writeoff.approve
Manage airlines inventory.edit
Link/unlink block to group inventory.allocate
List services outside their departure's dates (GET /inventory/outside-trip-dates) — INV-003/032 groups.view or inventory.view (requireAnyPermission; DB inventory_outside_trip_dates)
Sell seats to B2B inventory.edit
File a B2B sale cancellation (POST /finance/b2b-cancellations, Cancel Sale) — INV-014 finance.create (the browser-built filing), or inventory.b2b_cancellations.file (DB file_b2b_cancellation, one transaction). The decision stays finance.cancellations.approve_b2b (§6.8)
Buyer list for a seat sale (GET /inventory/party-accounts → inv_party_accounts(): party ledgers only) inventory.edit (or finance.view)
Record supplier payment (POST /inventory/quota-blocks/:id/finance-events eventType: supplier_payment) — FIN-044 finance.create (the browser-built voucher), or inventory.block_payments.record (DB record_airline_block_payment: pending, guarded, TDS-aware). The Post Payment button shows for either
Record refund from the airline (POST /inventory/quota-blocks/:id/airline-refund) — FIN-044. Pending finance (FIN-032); capped at what was paid so far; a fully cancelled block still takes it inventory.block_payments.record (<PermissionGate>; the route's requirePermission; DB record_airline_block_refund checks it again)
Paid-from account list for a recorder without finance.view (GET /inventory/paid-from-accounts) inventory.block_payments.record or finance.view (DB airline_block_paid_from_accounts)
Released-seat register (GET /inventory/released-seats?blockId=) — CXL-012 inventory.view
Mark released seat re-used (POST /inventory/released-seats/:id/reuse) — CXL-021 inventory.edit (DB: inventory.edit or bookings.edit)
File airline cancellation against released / unallocated seats — CXL-020 finance.create (DB-enforced in file_airline_cancellation)
Airline release/cancellation penalty bands (GET/POST /inventory/penalty-bands, DELETE /inventory/penalty-bands/:id) — AIR §20 inventory.view to read, inventory.edit to maintain (DB upsert_release_policy_band, delete_release_policy_band)
Block FOC summary and P&L (DB block_foc_summary, block_pnl) — AIR §15, §25 inventory.view or finance.view
/inventory/quota, /inventory/fit Fetch flight details — the flight schedule from AirLabs (edge function flight-lookup; the key is its secret, never in the browser) — PLT-015 inventory.view, staff only (checked in the function with user_has_permission)
/inventory/holds View holds board — AIR §14, INV-011 inventory.view
Hold seats/rooms/places (reason) inventory.holds.manage (DB create_inventory_hold)
Extend a hold (reason, capped by FinanceConfig.maxHoldExtensions) inventory.holds.manage (DB extend_inventory_hold)
Convert a hold to its booking (reason) inventory.holds.manage (DB convert_inventory_hold)
Release a hold (reason) inventory.holds.manage (DB release_inventory_hold)
Run the hold-expiry sweep (preview, then commit) inventory.holds.manage (DB expire_inventory_holds; the service role may run it unattended)
Resource position (GET /inventory/holds/position) inventory.view (DB inventory_resource_position)
/inventory/releases View seat releases — AIR §16–§18 inventory.view
Release preview (GET /inventory/releases/preview) — AIR §16 inventory.view (DB preview_seat_release)
Request a release (reason) inventory.release.request (DB request_seat_release)
Withdraw your own request inventory.release.request (DB cancel_seat_release)
Approve / reject a release — not the requester (ACC-020), unless the requester holds inventory.release.approve_own inventory.release.approve up to the seat limit, inventory.release.approve_large above it (ACC-030; DB approve_seat_release, reject_seat_release)
Record the airline filing — posts through the cancellation chain (CXL-020) finance.create (DB complete_seat_release → file_airline_cancellation)
/inventory/deadlines Deadline radar — AIR §21, INT-120 inventory.view (DB dash_deadline_radar)
Alert list (GET /inventory/alerts) — AIR §22 inventory.view
Acknowledge an alert (reason) inventory.view (DB acknowledge_inventory_deadline_alert)
Run the alert ladder / escalation now — AIR §22 inventory.edit (DB fire_inventory_deadline_alerts, escalate_inventory_deadline_alerts; the service role may run them unattended)
/inventory/fit View inventory.view
CRUD inventory.create / .edit / .delete
Record FIT supplier payment (POST /inventory/fit/:id/finance-events eventType: supplier_payment) — FIN-044 finance.create, or inventory.block_payments.record (DB record_airline_block_payment, p_kind: 'fit')
Record refund from the airline on a FIT (payment dialog; POST /inventory/fit/:id/airline-refund → DB record_airline_block_refund, p_kind: 'fit') — FIN-044. Pending finance (FIN-032); capped at what was paid so far; shown only when something is paid, the FIT has a supplier and is not archived (GET /inventory/fit/:id/payment-summary, inventory.view) inventory.block_payments.record (<PermissionGate>; the route's requirePermission; DB record_airline_block_refund checks it again)
Released-seat register (GET /inventory/released-seats?fitId=) — CXL-012 inventory.view
Mark released seat re-used — CXL-021 inventory.edit
File FIT airline cancellation (POST /inventory/fit/:id/finance-events eventType: cancellation) — CXL-024 finance.create (DB-enforced)
/inventory/b2b-flights View inventory.view
Create offer inventory.create
Close offer inventory.edit
/inventory/ground-transfers View inventory.view
CRUD inventory.create / .edit / .delete
Assign / remove inventory.allocate

6.13 Suppliers (/suppliers)

Section Action Permission
Route View page inventory.view (semantically should be suppliers.view — tracked in §8)
Header Add supplier suppliers.create
Row / Dialog Edit supplier suppliers.edit
Dialog Delete supplier suppliers.delete
Ledger Add transaction — off until the supplier is approved; the database refuses a hand-entered bill or payment for a pending or rejected supplier (PTY-009) suppliers.edit (RLS: any of suppliers/finance/inventory/hotels/food write permissions)
Header Approve / Reject a new supplier (shown while it is pending; Approve also on a rejected one) — POST /suppliers/:id/decision → supplier_decide; a rejection needs a reason — PTY-009 suppliers.approve
Ledger Edit transaction — a posted voucher must be reversed first; the correction needs a reason suppliers.edit + finance.supplier_transactions.correct when the row carries a voucher (DB correct_supplier_transaction)
Ledger Delete transaction suppliers.delete + finance.supplier_transactions.correct when the row carries a voucher
Payment Apply TDS on supplier payment finance.tds.deduct

6.14 Hotels (/hotels)

Section Action Permission
Route View page hotels.view
Header Create hotel hotels.create
Row Edit hotel hotels.edit
Row Delete hotel hotels.delete
Any Export hotels.export

6.14a Food (/food)

Food inventory was split out of the Hotels page in 2026-04. Hotel and food now have independent permission families and live on separate routes; the legacy /hotels/food/* API paths are kept as aliases for one release.

Section Action Permission
Route View page food.view
Header Create food item food.create
Header Import food CSV food.create
Row Edit food item food.edit
Row Delete food item food.delete
Any Assign food to group food.edit
Row Correct a meal plan — days, rate, dates, exclusions, a typed head-count (INV-042; PATCH /food/assignments/:id) food.edit
Any Export food.export

6.15 People (/people, /people/employees, /people/account-deletions)

Section Action Permission
Route /people View directory customers.view
Route /people/employees View staff admin.view
Route /people/account-deletions Account deletions: travellers' and partners' deletion requests (every deletion is a request since 2026-10-01; each shows an answer-by date 30 days on) and the deletions done (account_deletion_requests); linked from the Customers card (customers.edit) and the Agents card (partners.edit) — AUD-027 customers.view for the route; the list shows travellers to customers.edit and partners to partners.edit (the database decides)
Account deletions Complete deletion — the office's approval — once nothing is in the way (money held for the person included) (edge function account-delete complete → account_deletion_complete, service role, permission checked on the verified staff id); Finish a follow-up (finish); Decline with a note the person reads (account_deletion_decline) — AUD-027 customers.edit (a traveller) / partners.edit (a partner)
User mgmt Create user (Add employee, POST /users → admin-users create_user) admin.users.create
User mgmt Create user → choose a role: the login is made with no staff role and the role becomes a request filed by the creator (admin-users create_user roles → role_change_request_create_as) — ACC-082 admin.users.create and roles.request (the field shows only with roles.request)
User mgmt Create user → choose an administrative role (CEO, GM, HR Admin, IT Admin, Super Admin today — role_is_administrative()): offered and accepted only for a holder of roles.apply; anyone else is refused before the login is made — ACC-083 roles.apply (IT_ADMIN, SUPER_ADMIN) as well as the above; the list comes from GET /role-requests/roles (requestable)
User mgmt Reset password (POST /users/:id/password → admin-users reset_password); Reset MFA (POST /users/:id/mfa-reset) admin.users.reset_password; admin.mfa.reset — each only on a login you can manage (can_manage_user)
User mgmt Request a role change for a staff login — add or remove one role, with a reason (RoleRequestDialog → POST /role-requests → role_change_request_create). Not offered on a customer or partner login — ACC-077, ACC-080 roles.request
User mgmt Request a role change that adds or removes an administrative role — hidden from anyone without roles.apply, and refused by the database for them (RoleChangeRequest_admin_roles_by_admins) — ACC-083 roles.request and roles.apply
User mgmt Deactivate / reactivate (PATCH /users/:id { action: 'deactivate' \| 'reactivate' }), delete (DELETE /users/:id) admin.users.delete, only on a login you can manage (can_manage_user)
User mgmt ⋯ menu and record header: the access actions (change email, change username, pause / resume, reset password, reset MFA, deactivate / reactivate) show only where the caller can manage the login — canManage = can_manage_user(caller, person), the check the server makes; read in the same one call (GET /users?view=directory → computed field employee_can_manage; the record's profileEdit.canManage). Elsewhere: "You can't manage this login's access" — ACC-084 the action's own permission and canManage
User mgmt Open the employee record (/admin/users/:id, GET /users/:id/record → employee_record; also GET /users/:id/profile → admin_user_profile) — ACC-071 admin.users.view
Record → Documents Review a document: OK, or reject with a note (POST /users/:id/documents/:docId/review → employee_document_review) — ACC-071 admin.users.edit
Record → Documents Open a document in the viewer (drive-file, kind employee_file: EmployeeDocument row security) — ACC-074 admin.users.view
Record → Documents Upload a document for the person (upload-employee-doc with userId) — ACC-071, ACC-074 admin.users.edit
Record → Access / Activity Sessions, devices, sign-ins and the merged timeline (audit rows as action, field names and actor — AUD-004) admin.users.view (inside employee_record)
User mgmt Edit profile (PATCH /users/:id/profile → admin_update_employee_profile) — every field, over someone whose every right you hold (can_manage_user) or your own row — ACC-071; remove a document (DELETE /users/:id/documents/:docId) admin.users.edit
Record header, ⋯ menu Edit profile for HR — the profile details only (name and work phone read-only) of a staff login that is not an IT Admin or Super Admin; shown on the record only where profileEdit.allowed is true (the ⋯ menu offers it to admin.users.edit or hr.profiles.edit, and the dialog says why when profileEdit refuses); a refused save says "HR can't edit an IT Admin or Super Admin profile." — ACC-084 hr.profiles.edit (the database decides through profile_edit_rights())
User mgmt Edit profile → Reports to: pick from the active staff, never the person or anyone below them; designation suggestions (GET /users/:id/reporting-options → staff_reporting_options; the trigger EmployeeProfile_reports_to_guard refuses a loop) — ACC-081 admin.users.view to list; admin.users.edit or hr.profiles.edit (ACC-084) to save
User mgmt Pause / resume with a reason (PATCH /users/:id { action: 'pause' \| 'resume' } → admin_pause_user / admin_resume_user) — ACC-070 admin.users.edit (the function also refuses self, anyone above you, and a paused caller)
User mgmt Change a staff login's email with a reason (PATCH /users/:id { action: 'change_email' } → admin-users change_email) — ACC-075 admin.users.edit (existing permission; the function also refuses anyone above you and any customer or partner login)
User mgmt Move staff to @alhudatravels.in (page header → More): preview, then run with a reason (POST /users/workspace-domain → admin-users move_staff_to_workspace_domain, dryRun) — ACC-075 admin.users.edit (existing permission; a login above your station is listed and not moved)

6.16 Admin (/admin)

Section Action Permission
Route View admin admin.view (role-name gate removed in Wave 2 — ACC-001/012)
Permissions matrix Edit role permissions admin.edit
Permissions matrix Edit user overrides admin.edit
Permissions matrix A user's roles — read only; PUT /permissions/users/:id answers 410 when roleIds is sent (ACC-077) —
Currencies CRUD admin.edit
Currencies → Exchange Rates Add, edit or delete a rate; Fetch Auto Rates; Clear Manual Rates (Today) — through DB set_exchange_rate / delete_exchange_rate, audited; a rate dated before today needs a reason (FIN-046) finance.rates.manage (was the admin.currency.* gates over a table any staff login could write, until 20261006100000)
Cities CRUD admin.edit
Integrations View settings + configured flags admin.integrations.view
Integrations → WhatsApp WhatsApp delivery problems: reset codes Meta refused, and booking notices that failed or found no approved template, last 30 days (GET /admin/whatsapp-delivery-failures → whatsapp_delivery_failures(), ACC-069, COMM-024) admin.integrations.view (checked in the function too)
Integrations Edit settings / set or clear secrets (write-only) admin.integrations.edit
Integrations Send test email / WhatsApp (server-side) admin.integrations.test
Integrations → WhatsApp → Message templates List the WhatsApp templates copied from Meta, with status and last synced (listWhatsAppTemplates() in src/services/whatsappTemplateAdmin.ts; the table's staff-read RLS; AUD-024) admin.integrations.view
Reminders Automatic e-mails by category: each category and whether its e-mails are on (notification_categories(), COMM-039); HR leave is listed, switched in Leave admin (hr.leave.admin, LV-039) admin.integrations.view (checked in the function too)
Reminders Switch a category's e-mails on or off — Finance (notification_category_save(), COMM-039; refuses hr_leave; a paused login refused; audited notification_category_changed) admin.integrations.edit (checked in the function too)
Reminders Booking reminder e-mail settings and the last 50 reminders (booking_reminder_settings(), COMM-014; the tab is hidden without it) admin.integrations.view (checked in the function too)
Reminders Change the settings, switch reminders on or off (booking_reminder_settings_save(), COMM-014; a paused login refused; audited) admin.integrations.edit (checked in the function too)
Reminders Automatic WhatsApp notices: settings, template names with their state in Meta, the last 50 notices (whatsapp_notice_settings(), COMM-025) admin.integrations.view (checked in the function too)
Reminders Switch a WhatsApp notice on or off, change its template names (whatsapp_notice_settings_save(), COMM-025; a paused login refused; audited) admin.integrations.edit (checked in the function too)
Integrations → WhatsApp → Message templates Sync from Meta (syncWhatsAppTemplates() → edge function whatsapp-templates-sync, which checks it again, staff only, not paused; AUD-024) admin.integrations.edit
Integrations → WhatsApp → Sign-up in the WhatsApp chat See the sign-up form's status in Meta and the wa.me link (signupFlowStatus() in src/services/whatsappFlowAdmin.ts → edge function whatsapp-flow-setup {action:'status'}, which checks it again, staff only; TRV-017) admin.integrations.view
Integrations → WhatsApp → Sign-up in the WhatsApp chat Set up WhatsApp sign-up form — create, upload and publish the WhatsApp Flow on the company's WhatsApp account and save its id (setUpSignupFlow() → whatsapp-flow-setup {action:'setup'}, which checks it again, staff only, not paused; audited; TRV-017) admin.integrations.edit
Integrations → WhatsApp → WhatsApp menu See whether the menu is on, its office hours and bank details (loadMenuSettings() in src/services/whatsappMenuAdmin.ts → GET /admin/communication-settings → integration_settings_public(); COMM-030) admin.integrations.view (checked in the function too)
Integrations → WhatsApp → WhatsApp menu Switch the menu on or off, change the office hours and the bank details, with a reason (saveMenuSettings() → PUT /admin/communication-settings; CommunicationSetting row policy; audited as integration_settings.update; COMM-030) admin.integrations.edit (row policy too)
Audit logs View admin.view
Audit logs Read raw AuditLog rows (RLS) admin.audit.view
Cancellation Policies Route /admin/cancellation-policies (list + read) groups.view (API GET also accepts admin.view)
Cancellation Policies Create / edit / delete a policy — bands, flat charges, percentages, kept items cancellation_policies.manage (API, RLS and save_cancellation_policy)
Cancellation Policies Work out a charge (cancellation_quote) bookings.cancel, bookings.cancel.approve or cancellation_policies.manage
Cancellation Policies Manual refund override on cancel (at approval) bookings.cancel.approve
Passenger categories Read age cut-offs (GET /config/passenger-categories) — PAX-002/003 any signed-in user
Passenger categories Change age cut-offs (PATCH /config/passenger-categories, RLS on PassengerCategoryConfig) admin.config.edit
Family relationships Read the list (GET /families/relationships, RLS on FamilyRelationship) — PAX-036 any signed-in user
Family relationships Add, rename, reorder or switch off a relationship (POST /families/relationships → family_relationship_save; never deleted; head stays on) — PAX-036 admin.config.edit

6.17 Reports (/reports) + Requests (/requests) + Communications (/communications, /whatsapp) + Chat (/internal/chat)

Route Permission
/reports reports.view
/requests requests.view
/communications customers.view (drift — should be dedicated communications.view future)
/whatsapp customers.view (drift — same)
/communications, /whatsapp, booking "Send message" Send / queue / bulk send, record consent → communications.send
/communications Staff push card → Send Notification → communications.send; the push-send edge function checks it (COMM-042)
/requests Change status (fixed set, note required) / add note → requests.edit (DB update_customer_request)
/internal/chat chat.view

6.18 Role dashboards (/app) — UX-010 / UX-011

The staff home renders a dashboard per job, chosen from the permissions the user holds (src/lib/dashboardProfiles.ts, re-exported by src/components/dashboard/widgets/profiles.ts). The native app's Dashboard tab uses the same file (an identical copy) and the same widget permissions below (§6.5d). The route itself stays open to every staff user (allowedRoles={['admin','staff']}) so a user without a matching profile still lands somewhere and is told why; every widget on it is permission-gated twice — in the browser (anyPermission in the widget registry) and in the database (dash_* functions in 20260919140000_role_dashboards.sql raise 42501). Nothing here introduces a new permission.

Widget (section) Permission — any of Enforcement
Waiting for your decision (work) approvals.approve, finance.bookings.approve_finance, finance.payments.verify, finance.journals.approve, finance.refunds.approve, bookings.cancel.approve, finance.cancellations.approve_b2b, finance.cancellations.approve_airline, tickets.approve DB dash_approvals_inbox (each category included only for the permission that decides it; items you created are excluded — ACC-020)
Bookings to approve / Ticketing exceptions / Refunds requested approvals.approve / tickets.approve / finance.refunds.approve DB dash_approvals_inbox with a category filter
Groups at risk, Departures next 30 days, Travelling today groups.view, approvals.approve, hotels.edit, inventory.edit, visa.edit, tickets.approve DB dash_departures, dash_travel_today (money shown only with finance.view)
Exceptions (audit feed) admin.audit.view (+ finance.view in the DB) DB dash_exceptions
Bookings this season / this month bookings.view DB dash_bookings_season, dash_sales_numbers
Collections this month (and collected today under it) finance.reports.day_book.view, finance.reports.profit_loss.view DB dash_collections_month (its today field)
Receivables overdue (and the customer / partner ledger split under it) finance.reports.aging.view, finance.reports.receivables_payables.view DB dash_receivables_overdue (its ledger field)
Cash & bank finance.reports.balance_sheet.view DB dash_cash_position
Supplier payments due finance.reports.receivables_payables.view, finance.edit DB dash_payables_due
Receipts to verify finance.payments.verify DB dash_receipts_to_verify (own receipts excluded)
Bank lines not matched finance.edit DB dash_bank_unmatched
TDS to deduct and deposit finance.tds.view, finance.tds.deduct DB dash_tds_pending
Vouchers you prepared finance.create, finance.edit, finance.journals.reverse DB dash_my_journals
Period status & close blockers finance.view (+ a reports/period/audit permission in the DB) DB dash_period_status
Collected today by mode / My receipts awaiting verification finance.payments.record, finance.create DB dash_my_collections (own records only)
Leads to follow up leads.view DB dash_leads_followup
Quotations expiring quotations.view DB dash_quotations_expiring
Drafts not submitted / Sent back for correction / Dues before departure bookings.view DB dash_draft_bookings, dash_corrections, dash_dues_near_departure (p_scope = 'mine' filters by createdBy / correctionOwnerId)
Customer requests / Partner requests requests.view DB dash_customer_requests (p_scope = 'mine' filters by assignedToId)
Groups nearly full groups.view, bookings.create DB dash_seats_running_out
Partner applications / Partner sales agents.view DB dash_partner_applications, dash_partner_sales
Partner bookings pending bookings.view DB dash_partner_bookings_pending
Partners near their credit limit agents.ledger.view, finance.view (+ agents.view in the DB) DB dash_partner_credit
Visas not issued / Visa cut-offs visa.view, groups.view DB dash_visa_deadline
Documents missing / Rejected cases / Cases by stage visa.view DB dash_visa_documents_missing, dash_visa_rejected, dash_visa_stages
Intake queue visa.intake.view DB dash_visa_intake
Rooming not assigned hotels.edit, hotels.view, groups.view DB dash_rooming_unassigned
Deadlines next 7 days tickets.view, inventory.view DB dash_ticket_deadlines
Tickets to issue tickets.view DB dash_tickets_to_issue
Airline cancellations inventory.view, tickets.view DB dash_airline_cancellations
Seats held, sold and released inventory.view DB dash_block_utilisation
Deadline radar (T-7/3/1/0) inventory.view, tickets.view DB dash_deadline_radar
Holds expiring inventory.view, bookings.view DB dash_hold_expiry
Seat releases inventory.view, tickets.view, finance.view DB dash_seat_releases
Users without two-factor / Dormant accounts / Active users by role admin.users.view DB dash_users_without_mfa, dash_dormant_accounts, dash_users_by_role
Privileged access changes admin.audit.view DB dash_privileged_grants
Frequent actions (shortcuts) each link carries its own permission (bookings.create, quotations.create, agents.create, finance.payments.record, admin.permissions.view, …) hasPermission() per link

6.19 My profile (/me) — ACC-070, ACC-071

One page for the signed-in person (staff, tour leader, partner; allowedRoles=['admin','staff','agent']). No staff permission: every read and write is the person's own row, decided by the database.

Section Action Permission Enforcement
Route Open /me (Settings menu → My profile; a partner's sidebar My Profile; /partner/profile redirects) any staff or partner sign-in GET /me/profile → profile_me() (own row)
Your login Change phone (staff) own row PATCH /me/profile { patch } → profile_update_me (allow-list)
Personal details, address, emergency contact Change personalEmail, address, district, state, pinCode, emergencyContact*, dateOfBirth, bloodGroup, gender own row; simple Yes/No profile_update_me refuses any other field and a paused login
Employee details, roles Designation, code, department, joined on, reports to, Aadhaar / PAN last four, roles read-only changed by the office with admin.users.edit, or by HR with hr.profiles.edit (not the code or roles; ACC-084) (§6.15)
Documents List own files with drive links; upload a JPEG/PNG/WebP/PDF under 10 MB own row EmployeeDocument select own or admin; upload-employee-doc → employee_add_document (self); removal is the office's (§6.15)
Partner The agency, credit limit, documents, the business and registrations the office keeps (read-only) and the relationship manager; change name / contact person, address, website and contacts; upload a document own agency §6.11 (PTR-086)
Request account deletion A partner asks the office to delete their login (the card at the foot of /me; staff do not see it); always a request — AUD-025 … AUD-027 own login edge function account-delete → account_delete_self
Paused Everything reads; a save or an upload is refused and shown as a toast — auth_assert_can_write() in every function; assertCanWrite in the upload functions

6.13 Work inbox (/work) - WRK-001 to WRK-018

Section Action Permission Enforcement
Route View page work.view <ProtectedRoute>
Tabs My work, Waiting to be picked up work.view always shown
Tabs My queues work.view.queue tab hidden without it; work_inbox_page refuses the scope
Tabs Everything work.view.all tab hidden without it; work_inbox_page refuses the scope
Header Log a job (with due time and priority, WRK-018) work.create <PermissionGate>; DB work_item_create
Log a job Give it to someone else on create (phone only today) work.assign; yourself needs work.claim DB work_item_create_as
Row Claim work.claim <PermissionGate>; DB work_item_claim (atomic)
Row Open (projected row) the owning page's permission the linked screen gates it (WRK-001)
Drawer Hand over work.assign, or the job's holder with work.claim shown to a manager or the holder; DB work_item_assign checks which (WRK-007)
Drawer See suggestions work.assign DB suggest_work_assignee
Header Routing: read the rules work.view DB work_routing_list
Routing Change a rule work.assign shown when canEdit; DB work_routing_set (reason required)
Header Queues: read who is in each queue and who leads it work.view DB work_queue_members_list
Queues Add a person, make them member or lead, set weekly capacity points, take them off, bring them back work.queues.manage <PermissionGate>, shown when canEdit; DB work_queue_member_save / work_queue_member_remove (WRK-013), audited
Drawer Park / restart work.claim DB work_item_wait / work_item_resume (reason required)
Drawer Record the first answer, add a note work.claim DB work_item_record_response
Drawer Put it back work.claim DB work_item_release (reason required)
Drawer Finish work.close <PermissionGate>; DB work_item_close
Drawer Cancel work.assign <PermissionGate>; DB work_item_close(status=cancelled)
Drawer Reopen work.reopen <PermissionGate>; DB work_item_reopen

6.13a Performance (/performance) - PERF-001 to PERF-012

Where Action Permission Enforced by
Route View the page (your own numbers) perf.view.own ProtectedRoute; DB perf_scorecard via perf_require_view
Figure Open the jobs behind a number perf.view.own (own) / perf.view.all (someone else) DB perf_scorecard_rows via perf_require_view
Header Choose another person perf.view.all DB perf_people; each look writes an AuditLog row perf.viewed (PERF-011)
### 6.20 Leave (/leave, /leave/approvals, /leave/admin, /leave/holidays) — LV-001 … LV-062

Routes: handleLeave() in src/lib/leave.ts (Leave and agreements API). Screens: Leave.

Section Action Permission Enforcement
Route Open /leave (sidebar Leave) leave.apply <ProtectedRoute>; GET /leave/dashboard → leave_my_dashboard
Dashboard Apply for leave (preview as you type) leave.apply POST /leave/preview → leave_preview; POST /leave/applications → leave_apply (every check again; clientKey makes a double click harmless)
Dashboard Report an absence (LV-042) leave.apply leave_apply with isUnplanned
My applications Open one, with its timeline own, routed to you, or HR GET /leave/applications/:id → leave_application_detail (row checks in the function)
My applications Withdraw a pending one; cancel approved leave not started; ask to cancel leave that has started leave.apply (own) POST /leave/applications/:id/cancel → leave_cancel
My applications Attach a medical certificate (LV-014) leave.apply (own) upload-employee-doc (medical_certificate) → employee_add_document; POST /leave/applications/:id/certificate → leave_attach_certificate
Ledger Read one's own ledger for a leave year leave.apply GET /leave/ledger → leave_my_ledger
Route Open /leave/approvals (sidebar Leave approvals needs hr.leave.approve) leave.apply; what is listed is decided by the routing <ProtectedRoute>; GET /leave/inbox → leave_inbox
Approvals Approve or Refuse (a refusal needs a reason) the routed approver: hr.leave.approve; an HR applicant's reporting manager; hr.leave.approve_peak for the management stage — never the applicant POST /leave/applications/:id/decide → leave_decide (leave_can_decide_hr, leave_can_decide_mgmt)
Approvals Agree or refuse a cancellation of leave that has started hr.leave.approve, or the routed reporting manager POST /leave/applications/:id/cancellation → leave_decide_cancellation
Team Who is away (up to three months) HR and management see everyone; anyone else their direct reports GET /leave/team → leave_team
Route Open /leave/admin (sidebar Leave admin) hr.leave.admin <ProtectedRoute>; GET /leave/admin → leave_admin_screen
Leave admin Change settings (weekly off, sandwich rule, leave e-mails on or off (LV-039), windows, carry-forward cap, encashment, certificate days, company name and address, signatory) hr.leave.admin PATCH /leave/admin/settings → leave_settings_save (allow-list, audited)
Leave admin Change a leave type (days, on / off, half days, name, description) hr.leave.admin PATCH /leave/admin/types/:code → leave_type_save (CL/SL, EL, comp-off and LWP cannot be switched off)
Leave admin Declare or remove a peak season hr.leave.admin POST/DELETE /leave/admin/peak-seasons → peak_season_save / peak_season_delete
Leave admin Set an employee's confirmation date, notice period, weekly off hr.leave.admin; never one's own PATCH /leave/admin/employees/:id → hr_set_employee_leave_profile
Leave admin Record a comp-off hr.leave.admin; never one's own POST /leave/admin/compoff → leave_compoff_credit
Leave admin Opening balance, grant or adjustment (with a reason) hr.leave.admin; never one's own POST /leave/admin/adjustments → leave_adjust
Leave admin Run the accruals by hand (up to today) hr.leave.admin POST /leave/admin/accruals → leave_run_accruals
Leave admin Year end: preview, then close hr.leave.admin GET/POST /leave/admin/year-end → leave_year_end_preview / leave_year_end_run (once a year, after 31 March)
Leave admin Open an employee's register (Register on the Employees row); Download CSV hr.leave.admin or hr.leave.approve GET /leave/register/:userId → leave_register
Leave approvals, Leave admin → Register Cancel this leave on someone else's pending or approved application (with a reason; the days go back) hr.leave.admin; offered when the detail answers canHrCancel POST /leave/applications/:id/cancel → leave_cancel (LV-033)
Leave admin Record an absence for an employee, older than the reporting window, already approved hr.leave.admin; never one's own POST /leave/admin/absences → leave_hr_record_absence (LV-042; audited)
Route Open /leave/holidays (from the Leave page) any staff login <ProtectedRoute allowedRoles={['admin','staff']}>; GET /holidays → holiday_list
Holidays Add, change, confirm or remove a holiday hr.holidays.manage POST/PATCH/DELETE /holidays → holiday_save / holiday_delete (audited)

6.21 Employee agreement (/me/agreement, /hr/agreements) — EA-001 … EA-019

Screens: Employee agreement.

Section Action Permission Enforcement
Route Open /me/agreement (header Settings menu → My agreement, shown with leave.apply) and /me/agreement/:id any staff login; one's own agreements only <ProtectedRoute allowedRoles={['admin','staff']}>; GET /me/agreement → agreement_my; GET /agreements/:id → agreement_get
My agreement Sign (read, tick, typed name, drawn signature, password) the employee named in it; no permission POST /agreements/:id/sign → agreement_sign (password checked in the database; five failures lock signing for fifteen minutes)
My agreement Print or save as PDF whoever may read it the browser's Print; nothing on the server
Every page / app home The bar "Your employee agreement is waiting for your signature" (and, for a countersigner, how many wait) — EA-016 any staff login; one's own badge_counts() (agreementToSign, agreementsToCountersign) and, on the app's home, agreement_waiting_for_me — counts only, no text, no pay
Route Open /hr/agreements and /hr/agreements/:id (sidebar Employee agreements) hr.agreements.view <ProtectedRoute>; GET /hr/agreements → agreement_hr_list; GET /agreements/:id → agreement_get
Employee agreements Prepare and preview; Send to the CEO for approval (EA-017) hr.agreements.issue POST /hr/agreements/prepare → agreement_prepare; POST /hr/agreements → agreement_issue (status pending_approval)
Employee agreements / agreement page Review and approve / Approve and send; Reject with a reason (EA-017) hr.agreements.approve; never one's own or one they prepared POST /agreements/:id/approve → agreement_approve; POST /agreements/:id/reject → agreement_reject
Employee agreements Pick the reporting officer from the staff; designation suggestions (EA-012, ACC-081) hr.agreements.issue (or admin.users.view) GET /users/:id/reporting-options → staff_reporting_options
Employee agreements Also save these to the employee's profile — the appointment used goes back to the profile on issue (EA-013) hr.agreements.issue and the right to edit that person's profile: hr.profiles.edit over a staff login that is not an IT Admin or Super Admin (ACC-084), or admin.users.edit over that person (can_manage_user); otherwise the agreement is issued and the write is skipped with the reason agreement_issue(…, p_save_to_profile) → agreement_profile_block / admin_update_employee_profile (audited)
Employee agreements Withdraw an unsigned issue (with a reason) hr.agreements.issue POST /agreements/:id/withdraw → agreement_withdraw
Employee agreements Countersign hr.agreements.countersign; never one's own POST /agreements/:id/countersign → agreement_countersign
— Change or delete an agreement nobody the triggers EmployeeAgreement_guard, EmployeeAgreement_no_truncate, AgreementTemplate_guard (EA-007)

6.22 Role requests (/admin/role-requests) — ACC-077 … ACC-080, ACC-082, ACC-083

Routes: handleRoleRequests() in src/lib/roleRequests.ts (Access and roles API). Screens: Admin → Role requests.

Section Action Permission Enforcement
Route Open the page (sidebar Role requests needs roles.request); a login without any of the three sees only the requests it made or that are about it any staff login <ProtectedRoute allowedRoles={['admin','staff']}>; GET /role-requests?scope= → role_change_requests (row policy again inside)
Header Request a role change — person, add or remove, role (staff roles only), reason roles.request <PermissionGate>; POST /role-requests → role_change_request_create (one open request per person, role and direction; a double click returns the first)
Header …for an administrative role (add or remove) — the dialog offers it only when GET /role-requests/roles says requestable (ACC-083) roles.request and roles.apply the trigger RoleChangeRequest_admin_roles_by_admins refuses anyone else: "Only IT Admin or Super Admin can ask for the CEO role."
Waiting for the CEO Approve, or reject with a note — offered only when the row says canApprove (not the requester, not the subject) roles.approve <PermissionGate>; POST /role-requests/:id/ceo-decision → role_change_ceo_decide; on approval every other active holder of roles.apply gets a work-inbox job (WRK-018)
Waiting for an admin Accept and apply, or reject with a note — offered only when the row says canApply (not the requester, the subject or the approver) roles.apply <PermissionGate>; POST /role-requests/:id/admin-decision → role_change_admin_decide (writes UserRole in the same transaction; closes the jobs)
Mine Withdraw a request I made that is still open, with a reason roles.request (the requester) POST /role-requests/:id/withdraw → role_change_withdraw
History Applied, rejected and withdrawn requests with who decided what and when read as above role_change_requests('history')
Work inbox The admin's job opens this page on the request work.view workSubjectLink('RoleChangeRequest')
— Change a staff role any other way nobody the trigger UserRole_staff_role_lock; no INSERT/UPDATE/DELETE grant on UserRole for anon or authenticated (ACC-079)

7. Sidebar visibility

Staff navigation is one config array, STAFF_NAV_SECTIONS in src/components/layout/navConfig.ts, grouped by job (Wave 2). Each item is shown when hasPermission(item.permission) is true — never by role name — and a section with no visible item is hidden. A staff user who opens a page without its permission sees a 403 page naming the missing permission (ProtectedRoute → Forbidden). The footer shows the user's job title (from their role names — display only). Portal menus (customer, partner) are role-scoped (§6.10, §6.11).

Section Nav label Path Permission
Home Home (role dashboard) /app dashboard.view (route still uses the staff/portal split — §8 item 8)
Home Internal chat /internal/chat chat.view
Home Leave (also highlights /leave/holidays) /leave leave.apply
Sales Customers (also highlights Customer 360 /customers/:id) /sales/customers customers.view
Sales Leads /sales/leads leads.view
Sales Quotations /sales/quotations quotations.view
Sales Bookings /sales/bookings bookings.view
Sales Requests /requests requests.view
Sales Communications /communications customers.view (drift)
Sales WhatsApp /whatsapp customers.view (drift)
Sales People directory /people customers.view
Operations Work inbox /work work.view
Operations Journey board /operations/journey bookings.view
Operations Groups /groups groups.view
Operations Duty of care /operations/incidents incidents.view (declared by the incidents workstream, INC)
Operations Ops approvals /approvals approvals.view
Operations Corrections /corrections bookings.view (drift — should be approvals.view)
Operations Capacity /operations/capacity groups.view
Operations Cancellation policies /admin/cancellation-policies groups.view
Operations Hotels /hotels hotels.view
Operations Food /food food.view
Operations Ground services /inventory/ground-transfers inventory.view
Ticketing Tickets /tickets tickets.view
Ticketing Airline blocks /inventory/quota inventory.view
Ticketing FIT seats /inventory/fit inventory.view
Ticketing B2B flights /inventory/b2b-flights inventory.view
Ticketing Deadline radar /inventory/deadlines inventory.view
Ticketing Holds /inventory/holds inventory.view
Ticketing Seat releases /inventory/releases inventory.view
Visa Visa pipeline /visa visa.view
Visa Visa applications /sales/visa visa.intake.view
Finance Finance /finance finance.view
Finance Finance approvals /finance?tab=approvals finance.view
Partners & Suppliers Business partners /agents agents.view
Partners & Suppliers Suppliers /suppliers inventory.view (drift — should be suppliers.view)
Reports Reports /reports reports.view
Admin Admin /admin admin.view
Admin Employees & staff /people/employees admin.view
Admin Leave approvals /leave/approvals hr.leave.approve (management and reporting managers reach the page from the Leave page and their notifications; there is no badge count)
Admin Leave admin /leave/admin hr.leave.admin
Admin Employee agreements /hr/agreements hr.agreements.view
Admin Role requests /admin/role-requests roles.request (the route opens for any staff login; a CEO or admin holds roles.request too)

8. Known drift (as of this audit)

Items where code/matrix/seed don't fully agree. Each is tracked here; fixing them is incremental work.

  1. chat.view referenced but unseeded. App.tsx:187 requires it, but no migration inserts it. Fixed in this PR by supabase/migrations/20260416030000_seed_chat_and_dashboard_view.sql.
  2. dashboard.view referenced by Sidebar but no route guard uses it. /app route relies on allowedRoles={['admin','staff']} instead. Seeded this PR; tightening the route guard is a follow-up.
  3. /corrections uses bookings.view. Semantically this is an approvals/ops action; should migrate to a dedicated corrections.view. Tracked for a follow-up PR — when wired, add corrections.view to §4.
  4. /communications and /whatsapp use customers.view. Should migrate to a dedicated communications.view. Tracked; when wired, add communications.view to §4.
  5. /suppliers route uses inventory.view. Should migrate to suppliers.view. Tracked; when wired, add to §4.
  6. agents.edit / groups.delete / inventory.delete / inventory.allocate / quotations.delete not yet wired. The API enforces these actions via RLS and role-level grants, but there are no <PermissionGate> wrappers in the UI so row-level buttons remain visible regardless of permission. Tightening UI gates is a follow-up per PR; when added to code, re-add to §4. (agents.delete was wired and restricted to IT_ADMIN only — see migration 20260430140000.)
  7. admin.edit / admin.users.* / admin.config.edit / admin.audit.view not yet split. User management actions in <UserManagement> aren't separately gated; all live behind admin.view. Split when refactoring admin surface.
  8. Route-level allowedRoles=['admin','staff'] on /app bypasses the permission matrix for dashboard access. Deliberate since the role dashboards landed (Wave 2D): ProtectedRoute sends users without a permission back to /app, so gating /app itself would loop. Every widget on the page is gated instead (§6.18), and a user who matches no profile is told so.
  9. Partner & customer portals are role-gated only (allowedRoles={['agent']} / ['customer']). Actions inside portals are ownership-gated by the API, not matrix-gated. This is intentional (see §4.5) but means there is no single matrix row describing partner self-service capabilities.
  10. Granular permissions (§4.4) are seeded but mostly not yet wired in code. Migration 20260416050000 inserts all ~70 sectional permissions and grants the right role bundles (e.g. finance.reports.profit_loss.view to FINANCE_MANAGER but not ACCOUNTANT). The PermissionsMatrix admin UI surfaces them so admins can already toggle per-role. Currently wired: finance report button filtering in Finance.tsx (the user's explicit Accountant-vs-P&L example works). Not yet wired: export-button gates, workflow-split checks (e.g. finance.payments.verify still goes through coarse finance.create), admin tab-level splits (user management still behind admin.view), agent CRUD splits. The ALLOWED_DOC_ONLY list in src/test/permissions.matrix.test.ts enumerates exactly which permissions are seeded-but-unwired; each follow-up PR that wires a batch removes its entries from that list.

9. Governance (how this stays true)

When you add a page/section/action: 1. Add a row to §6 under the appropriate page. 2. If the permission is new, add it to §4 catalog and §5 grants matrix. 3. Add the corresponding row(s) to a new seed migration under supabase/migrations/. 4. Reference the permission in code: - Frontend: <PermissionGate permission="x.y"> around the action - Backend: await requirePermission('x.y') at the top of the handler - Route: <ProtectedRoute requiredPermissions={['x.y']}> if gating a whole page 5. Run npm test — the drift test will catch missing seed rows, orphaned permissions, or code that references permissions not in this file.

When you rename a permission: 1. Update §4 and §5 in this file. 2. Update every code reference in the same PR (grep the old name). 3. Add a migration that renames in the DB (UPDATE "Permission" SET name = ...) AND updates RLS policies referencing the old name. 4. Run npm test.

When you delete a permission: 1. Remove every code reference first. 2. Remove from §4 and §5. 3. Add a migration that drops the RolePermission rows and then the Permission row. 4. Run npm test.

The drift test is in src/test/permissions.matrix.test.ts. It will fail the build if: - Code uses a permission string not declared in this file - This file declares a permission that no code references (after a grace list for migrations)

When you write an RLS policy, never write AS RESTRICTIVE alone. A restrictive policy is ANDed onto whatever the permissive policies allow; with no permissive policy for that command there is nothing to AND with, so the command is denied to every caller — the permission holder and the super admin included — and RLS reports it as "no rows", never as an error. Use a plain (permissive) policy for the permission gate, and add AS RESTRICTIVE only for a rule that must hold on top of an existing permissive policy (immutability, a period lock, a deliberate deny-all). public.rls_restrictive_only_commands() lists every table/command that denies outright; supabase/tests/journal_write_paths.sql (FIN-074) fails the build when it returns anything not on its allowlist.


10. Canonical changelog of this matrix

Date Migration Change
2026-10-01 20261006100000 Exchange rates are managed by finance and dated (FIN-046, DECIDED 2026-10-01). Added finance.rates.manage — FINANCE_MANAGER, ACCOUNTANT, CEO, GM, SUPER_ADMIN; fin_role_bundle() (CEO, GM) and fin_role_finance_family() (ACCOUNTANT) name it so apply_role_bundles() keeps it. exchange_rates was writable by any staff login (policy "Staff can manage exchange rates", FOR ALL, is_staff_user()); that policy is dropped and INSERT / UPDATE / DELETE are revoked from anon and authenticated — reads are unchanged. New set_exchange_rate() and delete_exchange_rate() (SECURITY DEFINER, fin_require('finance.rates.manage'), audited). POST /finance/fx-rates/refresh moves from finance.edit to finance.rates.manage; the Exchange Rate Management buttons in Finance → Settings and the Exchange Rates tab in Admin → Currencies are gated on it (they were finance.edit and admin.currency.*). IT_ADMIN keeps admin.currency.* for currencies and no longer changes rates. Test: supabase/tests/exchange_rates_are_managed_and_dated.sql
2026-10-01 20261006120000 Finance guards in the database (PTR-030 DECIDED 2026-10-01, ACC-020, ACC-030). Added partners.credit.override (FINANCE_MANAGER, CEO, GM, SUPER_ADMIN); fin_role_bundle() patched in place so CEO and GM keep it (FINANCE_MANAGER is not an exact bundle). The trigger "Booking_credit_limit" refuses any booking write that takes a partner over its credit limit — staff paths included — unless a holder sends a reason (creditOverrideReason, audited as partner_credit_override, never stored). approve_journal_entries skips a reversal whose original the approver made unless finance.journals.approve_own (making a reversal is not refused; post_journal_reversal is unchanged). The ₹25,000 journal limit (finance.approvals.high_value above it) now covers contras, on-account receipts and reversals on custom lines. §4.1, §4.4, §5.1, §5.2, §6.4, §6.8. Test: supabase/tests/finance_guards_in_the_database.sql.
2026-10-02 20261006130000 Notifications share one queue (COMM-037 … COMM-039, DECIDED 2026-10-02). No new permission. Admin → Reminders → Automatic e-mails by category reads with admin.integrations.view (notification_categories()) and switches a category with admin.integrations.edit (notification_category_save(), not paused, audited); hr_leave is refused there — its switch stays LeaveSettings.emailNotifications under hr.leave.admin. New service-role-only functions notify_enqueue, notify_category_enabled, notify_email_ok, booking_notice_recipient, payment_receipt_email_queue, payment_claim_rejected_email_queue; new table NotificationCategory (no browser access). The partner's Payments page reads its own rejected payments under the existing agency row policy.
2026-10-02 — Record refund from the airline on a FIT (web). No migration; no permission added, removed or regranted. The FIT payment dialog gains Record refund from the airline on inventory.block_payments.record. New routes: POST /inventory/fit/:id/airline-refund → DB record_airline_block_refund (p_kind: 'fit', unchanged since 20260929200000) and GET /inventory/fit/:id/payment-summary (inventory.view) → DB airline_block_payment_summary; §6 /inventory/fit.
2026-10-02 20261006140000 Invoices, airline refunds and account deletion (finance staff guide review). No permission added, removed or regranted. The web airline block screen gains Record refund from the airline on inventory.block_payments.record (new route POST /inventory/quota-blocks/:id/airline-refund → DB record_airline_block_refund, unchanged since 20260929200000); §4.1, §6.12. The group invoices tab button that makes a draft is now labelled Create draft invoice (group_invoices.create); §4.1 corrected: issuing posts nothing, Post to Finance posts. account_deletion_blockers re-created: a partner's pending payment claim on a group invoice is a blocker, and money reads ₹ (AUD-025).
2026-10-01 20261005160000 The WhatsApp menu (COMM-030 … COMM-036, DECIDED 2026-10-01). No new permission. Admin → Integrations → WhatsApp → WhatsApp menu reads with admin.integrations.view and saves with admin.integrations.edit through the existing /admin/communication-settings route (settings whatsapp_menu_enabled, whatsapp_menu_office_hours, whatsapp_menu_bank_details; default off). New POST /sales/customers/:id/documents/:docId/review → customer_document_review() (customers.edit, not paused). New service-role-only tables WhatsAppConversation, WhatsAppPayLink and functions whatsapp_menu_who, whatsapp_menu_identity, whatsapp_menu_booking_ids, whatsapp_menu_bookings, whatsapp_menu_missing_parts, whatsapp_menu_groups, whatsapp_menu_group, whatsapp_menu_enquiry, whatsapp_pay_link_issue / _use / _release / _order, whatsapp_menu_document_target, whatsapp_menu_document_add, whatsapp_menu_handoff, whatsapp_menu_last_staff_reply. razorpay-order takes { payToken } without a login (the public page /pay/:token). Test: supabase/tests/a_customer_uses_the_whatsapp_menu.sql.
2026-10-01 20261005140000 A customer signs up by chatting on WhatsApp (TRV-017, DECIDED 2026-10-01). No new permission. Admin → Integrations → WhatsApp → Sign-up in the WhatsApp chat: the status uses admin.integrations.view, Set up WhatsApp sign-up form uses admin.integrations.edit — both checked in src/services/whatsappFlowAdmin.ts and again in the new edge function whatsapp-flow-setup (staff only; setup refuses a paused login). New service-role-only tables SignupFlowToken, PasswordSetToken and functions auth_signup_flow_issue, auth_signup_flow_use, auth_password_set_token_issue, auth_password_set_token_use; auth_signup_create_login takes a proof channel (whatsapp_code default, whatsapp_flow). The public page /customer/set-password needs no permission (the link is the proof).
2026-09-30 20261004100000 The screens know whose access you manage (ACC-084, ACC-071, PRF-010). No permission added, removed or regranted. New computed field employee_can_manage(EmployeeProfile) = can_manage_user(auth.uid(), userId), read as canManage on GET /users?view=directory; profile_edit_rights() also returns canManage, so employee_record and admin_user_profile carry it in profileEdit. The Employees ⋯ menu and the record hide the access actions where it is false. §6.15. Test: supabase/tests/screens_know_whose_access_you_manage.sql
2026-09-30 20261003235500 HR edits the profile details of the staff, except an admin's (ACC-084, DECIDED 2026-09-30; EA-013 amended). Added hr.profiles.edit (ADMIN_HR, SUPER_ADMIN). fin_role_bundle() not re-created: ADMIN_HR is not an exact bundle and fin_role_revokes('ADMIN_HR') does not name it. New employee_profile_hr_fields(), admin_account_permissions() (roles.apply, admin.permissions.edit), user_is_admin_account(), user_is_staff_login(), profile_edit_rights(). admin_update_employee_profile accepts admin.users.edit + can_manage_user (every field, as before) or hr.profiles.edit (profile details of a non-admin staff login, not oneself); agreement_profile_block asks the same, so HR's agreement issue saves the appointment back for non-admin staff. admin_user_profile, employee_record and the update's answer carry profileEdit. PATCH /users/:id/profile accepts either permission. §4.6, §5.7, §6.15, §6.21. Test: supabase/tests/hr_edits_profile_details.sql
2026-09-30 20261003190000 Only an admin asks for an administrative role (ACC-083, DECIDED 2026-09-30). No permission added, removed or regranted; roles.apply stays with IT_ADMIN and SUPER_ADMIN only. New role_administrative_permissions() and role_is_administrative(role): a role is administrative when its bundle holds any of admin.users.create / edit / delete / reset_password, admin.mfa.reset, admin.permissions.edit, admin.roles.create / edit / delete, roles.request / approve / apply, admin.config.edit, admin.integrations.edit, admin.edit — today ADMIN_HR, CEO, GM, IT_ADMIN, SUPER_ADMIN. The trigger RoleChangeRequest_admin_roles_by_admins refuses a request to add or remove one unless the requester holds roles.apply; admin-users create_user refuses such a role before the login is made. role_change_roles() now returns [{ name, administrative, requestable }].
2026-09-30 20261003165000 A staff role changes only with two approvals (ACC-077 … ACC-080, ACC-082). Added roles.request (ADMIN_HR, GM, CEO, IT_ADMIN), roles.approve (CEO) and roles.apply (IT_ADMIN); SUPER_ADMIN all three. fin_role_bundle() re-created (CEO and GM split; IT_ADMIN's bundle gains two). admin.users.edit no longer covers roles; the UserRole insert guarded / delete guarded policies (which let admin.permissions.edit change roles in one step) are dropped and INSERT/UPDATE/DELETE on UserRole revoked from anon and authenticated. The trigger UserRole_staff_role_lock refuses any write of a staff role outside role_change_admin_decide() — for browser sessions, the service role and other definer functions alike; CUSTOMER, AGENT and TOUR_LEADER keep their flows; a direct database session passes with app.role_change_maintenance = 'on'. admin-users assign_role / remove_role file a request for a staff role; create_user makes the login with no staff role and files requests for the roles chosen. POST /users/:id/roles and roleIds on PUT /permissions/users/:id answer 410. New table RoleChangeRequest, page /admin/role-requests, work type role_change. §4.7, §5.8, §6.15, §6.16, §6.22. Test: supabase/tests/a_role_changes_with_two_approvals.sql
2026-10-02 20261008125500 The CEO approves an employee agreement (EA-017). New permission hr.agreements.approve for CEO and SUPER_ADMIN (§5.7), named in the CEO's exact bundle. agreement_issue (hr.agreements.issue) now prepares (pending_approval); agreement_approve / agreement_reject (hr.agreements.approve) decide, never one's own or one the caller prepared; agreement_my / agreement_get hide a pending version from the employee; agreement_hr_list and GET /hr/agreements also accept hr.agreements.approve. Routes POST /agreements/:id/approve, POST /agreements/:id/reject. Tests: an_agreement_is_signed_and_kept.sql, every_agreement_step_is_emailed.sql.
2026-10-02 20261008120000 New partners and suppliers wait for approval (PTR-095, PTR-096, PTY-009, PTY-010). New permissions partners.approve and suppliers.approve for GM, CEO, IT_ADMIN and SUPER_ADMIN (§5.9), named in fin_role_bundle(). Every new partner is pending (the column default, and the trigger Agent_approval_guard forces a browser insert to it); deciding a pending partner, or making active one never approved, needs partners.approve — in the trigger for a browser write and in partner_set_status. A new supplier is pending (Supplier.approvalStatus); the browser cannot write the approval columns; supplier_decide (suppliers.approve) approves or rejects; a hand-entered SupplierTransaction for a supplier not approved is refused. New routes POST /agents/:id/status and POST /suppliers/:id/decision. A new partner or supplier e-mails sales@alhudatravels.in (category onboarding) and puts a notice in each approver's inbox; the decision e-mails sales@. Test: supabase/tests/new_partners_and_suppliers_wait_for_approval.sql.
2026-10-02 20261008140000 Booking screens: the browser checks what the database checks (ACC-020, LC-020, PTR-023). No permission added, removed or regranted. POST /finance/bookings/:id/finance-approve now accepts approvals.approve or finance.bookings.approve_finance to approve and approvals.approve or finance.bookings.reject_finance to reject or send back — what finance_decide_booking already accepted. Assigning a correction owner is assign_booking_correction (approvals.approve, in the function); the browser's row write needed bookings.edit. A booking-level cancellation approval is one call to approve_booking_cancellation (bookings.cancel.approve). A partner resubmits its own sent-back booking (partner_resubmit_booking; resubmit_booking accepts the owning partner in place of bookings.edit). Test: supabase/tests/booking_screens.sql.
2026-10-02 20261008170000 Work → Queues: members and leads are named on a screen (WRK-013, WRK-009). Added work.queues.manage — CEO, GM, SUPER_ADMIN (trigger); named in the CEO and GM bundles of fin_role_bundle(). New work_queue_members_list() (work.view), work_queue_member_save() and work_queue_member_remove() (work.queues.manage, checked in the function; audited by audit_workqueuemember). Routes GET /work/queues/members, PUT / DELETE /work/queues/:queueId/members/:userId. Test: supabase/tests/work_queue_members.sql.
2026-10-03 20261003195000 The appointment comes from the staff record; a reporting officer is one of the staff (EA-012, EA-013, ACC-081). No new permission. New column EmployeeProfile.parentName (father's / guardian's name), edited through admin_update_employee_profile (admin.users.edit) and read through profile_of. staff_reporting_options() (admin.users.view or hr.agreements.issue) lists who a person may report to. agreement_issue gains p_save_to_profile: the appointment used is written back through admin_update_employee_profile, only when the caller holds admin.users.edit and can_manage_user — issuing never widens who may edit a profile. The trigger EmployeeProfile_reports_to_guard refuses a non-staff officer, oneself and loops (two CEOs may report to each other). §6.21, §6 User mgmt. Test: supabase/tests/the_appointment_comes_from_the_staff.sql
2026-10-03 20261003180000 Staff do not pay online (FIN-032, TRV-009, PTR-093 — DECIDED 2026-09-30). No permission added, removed or regranted. online_payment_actor() no longer answers staff for a login with finance.payments.record: only customer (the booking's customer or payer) or partner (its agency). razorpay-order refuses a staff login with 403. The app's staff booking screen loses Pay online. §6.5d has the row. Test: supabase/tests/staff_do_not_pay_online.sql.
2026-09-30 20261003234500 Automatic WhatsApp notices: booking confirmed, payment received with the receipt PDF (COMM-020 … COMM-025, AUD-024 amended). No new permission. Admin → Reminders → Automatic WhatsApp notices reads with admin.integrations.view (whatsapp_notice_settings()) and changes with admin.integrations.edit (whatsapp_notice_settings_save(), a paused login refused); both check in the database. whatsapp_delivery_failures() also lists failed notices (still admin.integrations.view). WhatsAppNoticeSetting and BookingWhatsAppNotice have no browser access; booking_whatsapp_notice_queue/claim/finish() and whatsapp_notice_template() are service role only. The edge function whatsapp-booking-notice takes the service-role key only; issue-document answers a service-role call for a receipt only. Test: supabase/tests/whatsapp_booking_notices.sql.
2026-09-30 20261003220000 + 20261003220100 Every booking e-mail names the booking; booking reminder e-mails (COMM-001 … COMM-014). No new permission. New booking_email_summary() is service role only (the mailer checks a browser caller can read the booking first). Admin → Reminders reads with admin.integrations.view (booking_reminder_settings()) and changes with admin.integrations.edit (booking_reminder_settings_save(), a paused login refused); both check in the database. BookingReminderSetting and BookingReminderLog have no browser access. booking_reminders_run() and its helpers are service role only (pg_cron job alhuda-booking-reminders). Tests: supabase/tests/every_booking_email_names_the_booking.sql, supabase/tests/booking_reminder_emails.sql.
2026-10-03 20261003110000 WhatsApp templates are copied from Meta (AUD-024). No new permission. The template list uses admin.integrations.view; Sync from Meta uses admin.integrations.edit, checked in src/services/whatsappTemplateAdmin.ts and again in the new edge function whatsapp-templates-sync (staff only, a paused account refused; the nightly pg_cron job alhuda-whatsapp-templates-sync presents the dispatch key instead). New whatsapp_templates_sync_run() is service role only. WhatsAppTemplate RLS is unchanged. Test: supabase/tests/whatsapp_templates_come_from_meta.sql.
2026-09-29 20261003094100 – 20261003094300 Leave, the holiday calendar and the employee agreement (LV-001 … LV-062, EA-001 … EA-011). Added leave.apply (every staff role, not TOUR_LEADER, AGENT or CUSTOMER), hr.leave.approve, hr.leave.admin, hr.holidays.manage, hr.agreements.issue (ADMIN_HR), hr.leave.approve_peak, hr.agreements.countersign (CEO, GM), hr.agreements.view (ADMIN_HR, CEO, GM, AUDITOR); SUPER_ADMIN all. fin_role_bundle() re-created so apply_role_bundles() keeps them in the exact bundles. The leave and agreement tables grant SELECT only, under row security (own rows, the routed approver, the reporting manager, or the HR permission); every write is a SECURITY DEFINER function; the anonymous key reaches nothing (ACC-053). The ledger, the timeline and the year close are append-only; an application and an agreement are never deleted. §4.6, §5.7, §6.20, §6.21. Tests: supabase/tests/leave_follows_the_agreement.sql, supabase/tests/an_agreement_is_signed_and_kept.sql
2026-10-02 20261002110000 A WhatsApp reset code goes to the number typed; Meta's refusal is kept (ACC-069). No new permission. New whatsapp_delivery_failures() (SECURITY DEFINER, checks admin.integrations.view, anon revoked) and route GET /admin/whatsapp-delivery-failures; AuthLoginAttempt stays service-role only. auth_reset_contact(p_user_id, p_typed_phone) and auth_login_record(…, p_delivery_error_code, p_delivery_error) replace the shorter versions, service role only. Test: supabase/tests/a_reset_code_goes_to_the_number_typed.sql.
2026-09-28 20261001220000 A family is recorded per departure, never assumed (PAX-036); a minor names a guardian (PAX-021). No new permission: recording families, "not family" and guardians uses bookings.edit (it edits a booking's travellers; every role with groups.edit holds it); the relationship list uses admin.config.edit (the right that keeps the passenger-category settings). A partner records them for their own travellers only — the SECURITY DEFINER functions (family_save, family_remove_member, family_dissolve, traveller_set_family_status, travellers_set_family_status, traveller_set_guardian) check bookings.edit or the partner's ownership; none executable by anon. New tables FamilyRelationship, TravelFamily, TravelFamilyMember: read by staff with the rights that read BookingPassenger, by a partner or customer for their own travellers; no browser writes. BookingPassenger.familyStatus and guardianPassengerId cannot be written by a browser session. New routes under /families. Test: supabase/tests/families_per_departure.sql.
2026-09-27 20261001190000 A group is managed per traveller; the group leader is a person (PAX-035). No new permission. set_group_leader (groups.edit), place_travellers (bookings.edit), request_passenger_cancellation (bookings.cancel) and transfer_traveller_to_group (booking.transfer) — SECURITY DEFINER, each checks its right, none executable by anon. New routes POST /groups/:id/leader (groups.edit) and POST /groups/:id/travellers/place (bookings.edit). TravelGroup.groupLeaderPassengerId cannot be written by a browser session. The booking-level "Mark as Leader" is gone; the group page's leader picker no longer reads every customer, partner and user. Test: supabase/tests/a_group_works_per_traveller.sql.
2026-01-15 20260115000000 Initial 22-permission seed + admin-tier grants
2026-04-02 20260402210000 Added groups.view/edit/delete
2026-04-15 20260415200000 Permission-aware RLS on Booking/Customer/Agent/Supplier/Payment/Journal*/FinanceConfig
2026-04-15 20260415210000 Added customers.edit/delete, partners.*, suppliers.*, finance.edit, admin.edit + functional subsets
2026-04-15 20260415220000 Seeded all 13 functional role bundles
2026-04-16 20260416010000 Added *.view family + finance.payments.record; broadcast to staff roles
2026-04-16 20260416020000 Removed CEO/GM/IT_ADMIN role-name bypass from auth_user_has_permission(); grant every permission to those roles explicitly
2026-04-16 20260416030000 Added chat.view and dashboard.view to close drift with code
2026-09-17 20260917120000 AuditLog append-only (no UPDATE/DELETE/TRUNCATE for any role); raw SELECT restricted to admin.audit.view; scoped timelines via get_audit_timeline (bookings.view / customers.view / groups.view)
2026-09-18 20260918110000 Money integrity (Wave 1B): added finance.refunds.approve (CEO, GM, IT_ADMIN, ADMIN_HR, DIRECTOR, FINANCE_MANAGER). Payments recorded pending via record_payment and verified by another user via verify_payment; refunds via refund_payment / request_booking_refund + approve_refund; journal decisions via approve_journal_entries (finance.journals.approve / .reject / .bulk_approve); B2B cancellation decisions via decide_b2b_cancellation (finance.cancellations.approve_b2b); year close via close_financial_year (finance.years.close); rebuild gated on finance.ledger.rebuild. Posted vouchers, their lines, posted ledger entries and supplier transactions cannot be edited or deleted (service-role break-glass only); period lock on insert and update by entryDate; AccountingPeriod writes need the period permissions; B2BFlightOfferCancellation no longer open to every signed-in user; FinanceConfig readable again; ledger reset, GL force-delete and mark-paid removed
2026-09-28 20261001234000 PTR-084 … PTR-090 (partner profile). No new permission. PartnerProfile, PartnerContact, PartnerNote read with agents.view (contacts also by the partner for its own agency); written only by functions: partner_update_profile_admin, partner_contact_save / partner_contact_remove, partner_note_add, partner_add_document_for, partner_remove_document (partners.edit); partner_update_my_profile and the contact functions for a partner's own agency (auth_agent_id()); partner_list_extras (agents.view). Notes are append-only by trigger.
2026-09-25 20260929230000 PTR-083 / ACC-071 (records). No new permission. partner_record (agents.view), employee_record (admin.users.view), partner_document_review and partner_set_status (partners.edit), employee_document_review (admin.users.edit, and never above your station). A record's activity shows scoped audit rows without their values (AUD-004).
2026-09-25 20260929140000 ACC-070 / ACC-071. No new permission. user_has_permission() answers false for a paused account unless the permission's last segment is view, export or read; is_staff_user() now means "staff who may write". admin.users.view is wired (GET /users/:id/profile, admin_user_profile, admin_users_list, the app's Users screen). admin_pause_user / admin_resume_user take admin.users.edit, or partners.edit for a partner login, or customers.edit for a traveller login.
2026-09-18 20260918130000 Wave 1D: added communications.send (SALES_MANAGER, SALES_EXEC, OPS_MANAGER, OPS_EXEC, VISA_OFFICER, CEO, GM, IT_ADMIN); /operations/communications send moved from admin.users.edit to it. admin.integrations.view/edit/test wired (secrets write-only in IntegrationSecret; edge functions whatsapp-send, integration-test). Ticket issue/exception/name functions (tickets.edit/tickets.approve, maker-checker), visa status/intake/document functions (visa.edit, visa.intake.convert — intake PATCH no longer needs only visa.intake.view), update_customer_request (requests.edit); GET /requests needs requests.view
2026-09-18 20260918100000 Data isolation & portal functions (Wave 1A): staff read business tables by *.view permission sets; customers/partners read only their own/agency rows; is_staff_user() excludes AGENT and inactive users; open writes on BookingPassenger, GroupFlight, GroupExpense, B2B offers/passengers/cancellations, CancellationPolicy, FITInventory, CustomerDocument/Passenger become staff-only; portal actions via SECURITY DEFINER functions (§4.5); try_increment_group_booked_count / release_group_booked_count staff-only and no anon execute; b2b_transfer_seats requires inventory.create or inventory.edit; anon lost table grants and reads open groups via public_groups()
2026-09-17 20260917150000 Security lockdown part 1: no self-grant / no granting permissions or roles you don't hold (UserRole, RolePermission, UserPermission); Role/Permission catalogue migration-only; open UPDATE/DELETE removed from JournalEntry/JournalLine/LedgerEntry; UserSession own-rows; finance config tables permission-gated; WhatsApp + CommunicationQueue staff-only; User credential columns unreadable; deactivated users lose permissions; finance PIN verified in DB with lockout; portal self sign-up via self_assign_portal_role / self_register_customer. Edge functions admin-users, permissions-admin, auth-admin-mfa-reset, signed-url, mailer, extract-passport, finance-copilot, upload-*, secret-selftest now authorize server-side; whatsapp-webhook verifies Meta signatures
2026-09-19 20260919100100 + 20260919100200 F1 role bundles (owner decision 2026-09-17). New roles SUPER_ADMIN (the only role with every permission; a trigger grants it every permission added later) and CHARTERED_ACCOUNTANT. CEO/GM cut back to the leadership bundle (all *.view/*.export, business approvals, finance.supplier_transactions.correct); IT_ADMIN to system settings only; ADMIN_HR loses finance.*, approvals.*, group_invoices.*, bookings.cancel.approve, accounts.delete, group_pricing.edit, admin.integrations.edit, admin.mfa.reset; CASHIER keeps only finance.view + finance.payments.record; ACCOUNTANT loses approvals, period lock/unlock/close, year close and config; OPS_MANAGER loses journal approval and refund payout; SALES_MANAGER / B2B_MANAGER lose bookings.cancel.approve and refund payout. New permission finance.supplier_transactions.correct (ACCOUNTANT, CHARTERED_ACCOUNTANT, CEO, GM, SUPER_ADMIN). Break-glass finance.journals.approve_own / reverse_own, finance.ledger.rebuild/reset, finance.years.close, finance.periods.close, accounts.delete, admin.permissions.edit, admin.roles.*, admin.users.delete, agents.delete are super-admin only
2026-09-19 20260919100700 F1 approval limits (ACC-030): ApprovalLimit table with the working defaults, new permission finance.approvals.high_value (CEO, GM, SUPER_ADMIN). Enforced for customer refunds and manual journals; the other rows are configuration only for now. CEO/GM bundle also keeps the duty-of-care incident actions
2026-09-19 20260919100300 – 20260919100600 F1: recording money needs finance.payments.record and a booking refund payout finance.payments.refund; supplier transactions, TDS, financial years and finance configuration gated by permission (finance.supplier_transactions.correct for corrections); one atomic counter per document series; opening balances become vouchers and reports check the report's own *.view permission
2026-09-19 20260919100000 F1 journal controls: browser writes can only create pending vouchers and can never approve one; every reversal links to its original, follows its status and is capped at the original (post_journal_reversal); B2B cancellation vouchers post through post_decision_journal
2026-09-19 20260919130000 / 20260919130100 Duty of care (Wave 2C): added incidents.view, incidents.manage, incidents.close, incidents.confirm_death (CEO, GM: all four; OPS_MANAGER: view/manage/close; OPS_EXEC: view/manage; AUDITOR: view; IT_ADMIN/ADMIN_HR deliberately not granted — health data). Incident tables are readable only with incidents.view and writable only through open_incident, assign_incident_owner, update_incident_details, update_checklist_item, add_incident_checklist_item, add_incident_update, add_incident_document, change_incident_status (close needs incidents.close) and set_traveller_state (deceased needs incidents.confirm_death plus the typed incident number). BookingPassenger.travellerState can be set only through an incident; the private incident-documents bucket is readable with incidents.view and writable with incidents.manage
2026-09-18 20260918120000 Booking lifecycle (Wave 1C): status changes only via submit_booking (bookings.create/bookings.edit), ops_decide_booking (approvals.approve), finance_decide_booking (approvals.approve or finance.bookings.approve_finance / finance.bookings.reject_finance), resubmit_booking (bookings.edit), apply_booking_cancellation_status (bookings.cancel.approve); create_booking (bookings.create; typed price on a rate-sheet group needs group_pricing.edit); move_booking_to_group (groups.edit/booking.transfer/bookings.edit); delete_booking (bookings.delete, DRAFT without payments); release_passenger_allocations; PassengerCategoryConfig readable by all signed-in users, editable with admin.config.edit; cancellation approver ≠ requester; try_increment_group_booked_count revoked from anon. No new permissions
2026-09-20 20260920100200 Inventory counter integrity (Wave 3 I-A): added inventory.counters.repair (CEO, GM, IT_ADMIN, ADMIN_HR, DIRECTOR, OPS_MANAGER). Every inventory counter — airline block and FIT seats, hotel rooms and beds, meal-days, vehicle capacity — is now derived in the database and an over-allocation is refused rather than clamped (INV-012/INV-013). inventory_drift_report() is read-only and needs inventory.view; inventory_repair_drift() needs inventory.counters.repair, demands a reason and writes to InventoryDriftRepair (readable with inventory.view, never writable from a session). Airline blocks and FIT gain archivedAt/archivedBy/archiveReason with archive_*/restore_* functions on inventory.edit; a database trigger refuses deleting either when passengers, tickets, released seats, cancellations, B2B offers, finance rows or recorded edits hang off it (AIR §26)
2026-09-27 20261001130000 A verified payment has a receipt (FIN-045); a document is shared from the phone (TRV-015). No new permission. issue-document takes kind receipt (finance.view or bookings.view, or the booking's customer / payer / partner — checked in the function); IssuedDocument.kind and issue_document_record() accept receipt (a verified payment of the booking); the IssuedDocument select policy adds kind = 'receipt' AND auth_user_has_permission('finance.view'). §6.5d has the rows. Test: supabase/tests/a_receipt_is_a_document.sql
2026-09-26 20260930200000 A passenger sees their trip (TRV-001 amended). No permission changes, no row policy changes. New auth_passenger_ids() / auth_passenger_booking_ids() (SECURITY DEFINER, own login, anon revoked); trv_my_trips, trv_is_my_group, trv_set_location_sharing, trv_submit_passport (own row only) and trv_my_documents accept a passenger; the money and the other passengers' details are withheld. Test: supabase/tests/a_passenger_sees_their_trip.sql
2026-09-26 20260930190000 A closed departure is on sale nowhere (TRV-008). No permission changes. public_departures() and public_groups() leave out a departure whose statusOverride is cancelled, completed or departed; both answer fewer rows with the same columns and grants. Test: supabase/tests/the_traveller_app_works.sql, which also runs the traveller's and the tour leader's app reads as each kind of login
2026-09-26 20260930030000 inventory.release.file (SUPER_ADMIN, TICKET_MANAGER, TICKET_EXEC, finance.create roles): ticketing records an approved release's airline filing; file_airline_cancellation admits it only inside complete_seat_release; finance still settles the refund
2026-09-26 20260930000000 Ticketing owns its blocks (AIR §30): inventory.release.approve_own added (SUPER_ADMIN, TICKET_MANAGER) — a self-decided release is marked and audited; inventory.create granted to TICKET_MANAGER and TICKET_EXEC; the create-block deposit needs finance.create or inventory.block_payments.record
2026-09-20 20260920110200 Wave 3 I-B (holds, deadlines and seat release): added inventory.holds.manage (TICKET_MANAGER, OPS_MANAGER, OPS_EXEC, SALES_MANAGER, SALES_EXEC, B2B_MANAGER, B2B_EXEC), inventory.release.request (TICKET_MANAGER, OPS_MANAGER, OPS_EXEC), inventory.release.approve (TICKET_MANAGER, OPS_MANAGER) and inventory.release.approve_large (CEO/GM/IT_ADMIN only, via the super-admin trigger). TICKET_MANAGER also gained inventory.view and inventory.edit — AIR §30 makes it the owner of airline blocks and it previously held no inventory permission, so the sidebar linked it to pages it could not open. Holds, seat releases, penalty bands and deadline alerts are read through inventory.view / tickets.view / finance.view RLS and written only through SECURITY DEFINER functions (20260920110100); the ACC-030 seat limit lives in FinanceConfig.seatReleaseApprovalSeatLimit (default 10)
2026-09-21 20260921110000 Disaster recovery — the access model now survives a restore. Inserts a Role row for every RoleType value (driven off pg_enum) and re-applies this whole matrix as an explicit manifest. Before it only SUPER_ADMIN and CHARTERED_ACCOUNTANT were created by a migration, so on a database built from migrations alone every grant migration's JOIN public."Role" matched nothing and all grants for the other 18 roles were skipped silently: 2 roles / 167 permissions / 204 grants instead of 20 / 171 / 949. Added the four permissions that were referenced in code and documented in §4 but inserted by no migration — reports.view, admin.view, hotels.view, agents.delete — which made /reports, /admin and /hotels answer 403 to everyone on a fresh database, super admin included. Those three view permissions, and groups.view (previously leadership-only), now follow one rule: whoever holds a right in a module can open it, so ops, sales, B2B, visa and finance can reach the groups and reports they work on. The manifest is the post-F1 end state — nothing 20260919100100/100200/100300 revoked is handed back, with guards on the revoked sets on top. Additive and idempotent. Checked by supabase/tests/access_model.sql (run it after every restore) and by the migration-coverage tests in src/test/permissions.matrix.test.ts
2026-09-21 20260921120000 Release-rehearsal fixes — two rules that held on a seeded database but not on production data. Found by restoring the 2026-09-18 production backup into a local Postgres and running this release against it (docs/operations/release-rehearsal-2026-09-18.md). (1) Production carried an RLS policy auth_access (FOR ALL TO authenticated USING true) on AuditLog that no migration in this repo creates — it was applied to the project out of band. Permissive policies are OR'd, so 20260917120000's admin.audit.view restriction changed nothing there and every signed-in user, portal logins included, could still read the whole audit trail (AUD-004). This migration drops every policy on AuditLog except the two the release owns, so any other out-of-band policy goes too. (2) Production carried five RolePermission rows granting the portal roles staff permissions — AGENT → bookings.view, customers.view, quotations.view, dashboard.view and CUSTOMER → dashboard.view. §4/§5 have always said portal roles hold none, and apply_role_bundles() leaves portal roles alone (their bundle is empty), so nothing in the release cleared them; they are deleted here (ACC-001, ACC-012). No permission is added or removed. Checked by supabase/tests/access_model.sql (ACC-048) and supabase/tests/audit_trail.sql, both of which fail on restored production data without this migration
2026-09-22 20260922100000 + 20260922100100 Restrictive-only RLS policies denied writes to everyone, and three write permissions reached no operational role. (1) Eleven table/command pairs across nine tables carried a RESTRICTIVE policy with no permissive partner. PostgreSQL ANDs restrictive policies onto the permissive result, so with no permissive policy the command is denied to every caller, super admin included, and RLS reports it as "no rows" rather than an error — invisible until someone writes. Repaired by adding the permissive partner each restrictive policy was written to AND with, carrying the identical permission expression, on BookingPassengerMeal, BookingPassengerGroundService, GroupTemplate, JournalAttachment, JournalTemplate, exchange_rate_snapshots, VisaGroup, VisaIntakeRequest and VisaIntakeAttachment. InventoryDriftRepair's three false policies are a deliberate deny-all (append-only through its repair function) and are kept. The journal, journal-line and group-invoice write policies are re-asserted permissive so a database where the re-runnable 20260415200000 was replayed out of order converges instead of silently posting nothing to the ledger. No permission expression is relaxed; journal immutability (FIN-031), the pending-only rule for browser writes (FIN-032) and the accounting-period lock stay with their triggers. New guard rls_restrictive_only_commands() must return zero rows. (2) groups.edit → OPS_MANAGER, OPS_EXEC, SALES_MANAGER, TICKET_MANAGER; groups.view → TICKET_MANAGER; bookings.edit → OPS_MANAGER, OPS_EXEC, TICKET_MANAGER; suppliers.edit → ACCOUNTANT, FINANCE_MANAGER (§5.2 note). Nothing the F1 bundles revoked is handed back. Checked by supabase/tests/journal_write_paths.sql (FIN-060…FIN-077)
2026-09-25 20260929130000 An online payment starts with an order (FIN-032, TRV-009, PTR-081, ACC-063). No new permission. OnlinePaymentOrder (service role writes; authenticated reads only its own side under RLS — auth_customer_booking_ids(), auth_agent_booking_ids(), or finance.view / bookings.view); online_payment_actor(p_booking_id) answers customer / partner / staff (finance.payments.record) / null for the caller's session, anon revoked; the sign-in throttle gains action razorpay_order. Edge function razorpay-order; razorpay-webhook marks the order paid. §6.5d has the rows.
2026-09-25 20260929120000 An e-ticket and a hotel voucher are files (TRV-013). No new permission: issuing is gated on tickets.view + bookings.view (e-ticket) or hotels.view + bookings.view (hotel voucher), both checked in the edge function issue-document with user_has_permission. New table IssuedDocument (audited; RLS SELECT for the booking's customer via auth_customer_booking_ids(), its partner via auth_agent_id(), or bookings.view; no client write; anon revoked) and issue_document_record() (service role only). trv_my_documents() carries the live file on ticket and hotel rows; drive-file gains kinds ticket, hotel, issued. §6.5d has the rows.
2026-09-25 20260929100000 The partner in the app (PTR-004, PTR-080 … PTR-082). No new permission. auth_is_staff() and auth_identifier_resolve() now say staff = a staff role and no portal role, so a partner's login given TOUR_LEADER stays a partner (portal door, agency isolation, field.* only); isPortalOnlyUser in the edge functions agrees. New partner functions partner_my_account, partner_add_document, partner_submit_payment (each takes the partner from the session; anon revoked); AgentDocument.docType gains gst_certificate, address_proof, cancelled_cheque. Functions partner-signup and upload-partner-doc. §6.5d lists the screens.
2026-09-25 — The traveller as a customer (TRV-007 … TRV-009). No migration and no permission: sign-up in the app (self_register_customer), the tours (public_departures, public_groups), the booking request (customer_create_request, type group_interest), quotations (customer_respond_quotation), payment claims (customer_submit_payment) and the profile (customer_update_profile) are the portal functions of 20260917150000, 20260918100000 and 20260926150000, each taking the customer from the session. §6.5d lists the screens.
2026-09-25 20260928220000 The traveller's app (TRV-001 … TRV-006, FLD-006). Added programme.manage (SUPER_ADMIN and every role holding groups.edit: CEO, GM, SALES_MANAGER, OPS_MANAGER, OPS_EXEC, TICKET_MANAGER, ADMIN_HR) for the step-by-step guide (TripGuideStep, trv_upsert_guide_step, trv_delete_guide_step). No other permission: the programme of a departure (GroupActivity, GroupNotice) is written with groups.edit or by the group's leader and read by the group's travellers, partner, leader and groups.view (trv_trip_programme); live location (TravellerLocationShare, TravellerLocation) is switched on only by the traveller's own login and read by the leader and groups.view (fld_group_locations); a passport scanned in the app (TravellerPassportSubmission, trv_submit_passport) is applied only with bookings.edit (trv_review_passport_submission). All six tables are closed to anon and writable by nobody but their functions (ACC-053). Test: supabase/tests/the_app_for_travellers.sql
2026-09-25 20260929000000 A traveller sees their own documents (TRV-011). No new permission. trv_my_documents() (SECURITY DEFINER, authenticated only) lists the caller's own visa cases and files, e-ticket records (no PNR), hotel stays (no price) and customer documents; it widens no row policy — the hotel tables stay staff-only and are read only inside the function. Edge function drive-file opens a file for the caller when trv_my_documents() lists it, or for staff with customers.view, visa.view or bookings.view, as a ten-minute signed link; Drive files are cached in the private bucket traveller-documents (no client policy). §6.5d has the rows.
2026-09-23 20260923110000 Flexible cancellation policies (PRC-020 / PRC-023). Added cancellation_policies.manage (CEO, GM, SALES_MANAGER, OPS_MANAGER, FINANCE_MANAGER; SUPER_ADMIN by trigger). Changing a policy no longer needs admin.edit, and the tables no longer accept writes from any staff user — RLS on CancellationPolicy, CancellationPolicySlab and the new CancellationPolicyComponent now checks the permission. The page opens with groups.view and moved to Operations in the sidebar. cancellation_quote (needs bookings.cancel, bookings.cancel.approve or the new permission) works out every cancellation charge; save_cancellation_policy saves a policy in one transaction. Checked by supabase/tests/cancellation_policy.sql and ACC-049 in access_model.sql
2026-09-24 20260924130000 A transfer moves the passenger and their money together (LC-030, PRC-004, PRC-005). transfer_passenger_to_booking checks booking.transfer — the permission the transfer route already asks for — and posts the receivable move and any price difference as the system, pending for a second person (FIN-032). No finance permission is widened. BookingPassengerTransfer is readable with bookings.view or finance.view and written only by the function. No permission is added or removed. Checked by supabase/tests/transfer_price_override.sql
2026-09-25 20260929145900 + 20260929150000 Ticketing pays for its blocks, finance approves (FIN-044); the Ticketing Executive (AIR §30); a seat sale from the phone (INV-014). Added role TICKET_EXEC (functional; exactly tickets.view/edit, bookings.view/edit, groups.view, inventory.view/edit, inventory.block_payments.record, dashboard.view, work.view/create/claim/close — no approvals) and permission inventory.block_payments.record (SUPER_ADMIN, TICKET_MANAGER, TICKET_EXEC, FINANCE_MANAGER, ACCOUNTANT; not CHARTERED_ACCOUNTANT, whose exact bundle apply_role_bundles() rewrites — the CA keeps the desktop route on finance.create). record_airline_block_payment (SECURITY DEFINER) derives the desktop's supplier-payment voucher for a block or FIT and posts it pending through fin_post_system_vouchers, with the overpayment guard, contract-rate FX and the TDS rule in the database; the web routes send a holder without their own gate (finance.create / suppliers.edit) through it and leave finance's browser-built path unchanged. airline_block_payment_summary, airline_block_paid_from_accounts, block_third_party_sales read for the screens; sell_block_seats_to_third_party (inventory.edit) is POST /inventory/third-party-sale in one transaction. Nothing the finance-controls work revoked comes back; no finance.* permission changes hands. Tests: supabase/tests/ticketing_pays_for_its_blocks.sql, src/lib/api.blockPayments.test.ts
2026-09-25 20260929170000 A tour leader reads only its field (FLD-001, ACC-012). No new permission. New predicate auth_reads_beyond_field() = auth_is_staff() and at least one permission outside field.* (role or user-level allow, minus user-level deny). The staff-wide read policies on User, UserRole, UserPermission, Account, AccountingPeriod, ApprovalLimit, FinanceConfig, FinancialAccount, FinancialAccountTransaction, FinancialYear, the nine Chat* tables, CommunicationsLog, CommunicationSetting, WhatsAppContact / WhatsAppMessage / WhatsAppTemplate (their FOR ALL write policies too) use it instead of auth_is_staff(). A TOUR_LEADER-only login, or a partner who leads, sees one User row: its own. GET /users has no permission of its own — every desktop bundle holds a right beyond the field, so the pickers are unchanged. currencies, exchange_rates and the reference tables open to any signed-in user are unchanged. Test: supabase/tests/a_tour_leader_reads_only_its_field.sql.
2026-09-25 20260929180000 An allocation is read by its owner (ACC-010, FIN-040). No new permission. PaymentAllocation — which receipt settles which booking, invoice or group invoice, and for how much — was SELECT ... USING (true) for every signed-in login since 20260924113000, portal logins and tour leaders included, while the Payment row itself was already scoped. Replaced by three policies in the shape of Payment's: PaymentAllocation select staff (finance.view, bookings.view or agents.ledger.view via auth_user_has_permission), select customer (the allocation's booking, the receipt's booking or an allocated invoice's booking in auth_customer_booking_ids(), or a group invoice in auth_portal_group_invoice_ids()), select agency (the same four routes through auth_agent_booking_ids() / auth_agent_id()). anon re-revoked; the insert-none policy re-asserted. No reader changed: the finance register, the staff customer ledger and the booking-totals mirror read as staff, and neither portal nor the phone app reads the table. Test: supabase/tests/an_allocation_is_read_by_its_owner.sql.
2026-09-25 20260929200000 Ticketing files a sale's cancellation, records the airline's refund and the deposit at purchase (FIN-044 amended, INV-014). Added inventory.b2b_cancellations.file — filing only — to SUPER_ADMIN, TICKET_MANAGER, TICKET_EXEC and, computed in the migration, every role holding finance.create (CEO, GM, FINANCE_MANAGER, ACCOUNTANT, CHARTERED_ACCOUNTANT at the time; some of those bundles are exact sets apply_role_bundles() rewrites, so file_b2b_cancellation admits finance.create as well). file_b2b_cancellation (SECURITY DEFINER) is POST /finance/b2b-cancellations' filing in one transaction; the route sends a holder without finance.create through it. The decision is unchanged: decide_b2b_cancellation on finance.cancellations.approve_b2b, never the filer. record_airline_block_payment gains p_event (supplier_payment / initial_payment / supplier_refund; the 10-argument signature is dropped and replaced) and record_airline_block_refund wraps the refund; create_quota_block_and_post records a deposit through it in the block's transaction. No finance.* permission changes hands. Tests: supabase/tests/ticketing_files_and_refunds.sql, src/lib/api.blockPayments.test.ts, apps/mobile/src/lib/inventoryAirline.test.ts
2026-09-26 20260930010000 + 20260930040000 A block starts as a draft (AIR §36). No new permission. AirlineQuotaBlockDraft (RLS: SELECT for inventory.create or inventory.edit; no write policy — only the functions write it; anon has nothing). create_quota_block_draft (inventory.create), update_quota_block_draft and discard_quota_block_draft (inventory.create or inventory.edit), quota_block_draft_preview (the same), submit_quota_block (inventory.create; the deposit also inventory.block_payments.record inside record_airline_block_payment). create_quota_block_and_post is now draft + submit in one transaction. Guard triggers refuse a JournalEntry, LedgerEntry or SupplierTransaction naming an open draft. 20260930010000 reached production on 26 Sep 2026 as part of an earlier supabase db push, before the screens shipped; it is additive and there were no drafts, so live blocks were unaffected. 20260930040000 closes the four pure helpers (inv_draft_time, inv_draft_date, inv_draft_leg_problems, inv_draft_payload) that were left with the default PUBLIC execute. Tests: supabase/tests/a_block_starts_as_a_draft.sql, src/lib/api.blockDrafts.test.ts
2026-09-26 — Seat releases in the native app (AIR §16–§18, §30). No migration and no new permission: the app's Inventory → Seat releases calls preview_seat_release, request_seat_release, approve_seat_release, reject_seat_release, cancel_seat_release and complete_seat_release under the same permissions as the desktop (inventory.release.request, .approve, .approve_large, .approve_own, .file or finance.create). §6.5d has the rows.
2026-09-27 20261001090000 Files live on the company Shared Drive (ACC-074, ACC-073 amended). No new permission. drive-upload and drive-file gate each kind of file on the permission its screen already uses (upload: groups.edit, finance.edit, finance.tds.deduct, incidents.manage, visa.edit, tickets.edit, own customer record; open: groups.view, finance.view, finance.tds.view, incidents.view, visa.view, tickets.view, requests.view, leads.view, visa.intake.view, customers.view, bookings.view; partner, employee, issued and chat files under their own row security). drive-file's staff rule was customers.view or visa.view or bookings.view for any document; it is now the one permission of that kind. DriveFile (service role only). signed-url removed. Tests: supabase/functions/drive-file/index.test.ts, supabase/tests/files_live_on_the_shared_drive.sql
2026-09-30 20261004090000 A traveller or a partner deletes their own account (AUD-025 … AUD-027). No new permission. AccountDeletion (RLS: the person's own rows; travellers' rows to customers.edit, partners' to partners.edit; no write policy — only the functions write). account_delete_self, account_deletion_complete, account_deletion_finish, account_anonymise and account_deletion_blockers run for the service role only (the edge function account-delete checks the session, the password or the staff permission first); account_deletion_status for the signed-in person, account_deletion_requests and account_deletion_decline check customers.edit / partners.edit per kind. Staff logins are refused.
2026-10-01 20261005090000 Nobody deletes their own account; money held blocks (AUD-025, AUD-027 amended). No new permission and no grant changes. account_delete_self (service role only) now always files a request and never erases; the office's account_deletion_complete (permission checked on the verified staff id, customers.edit / partners.edit) is the only erasure. New internal functions account_deletion_reasons and account_deletion_money (service role only); account_deletion_blockers adds money held for the person; account_deletion_status and account_deletion_requests keep their grants.
2026-10-03 20261008200000 The travellers' guide in five languages (TRV-018 … TRV-021, DECIDED 2026-10-03). Added guide.religious.approve — SUPER_ADMIN only (trigger + backstop insert); the office grants it to named people. It approves religious text in the guide (verses, duas, their transliteration and meaning, anything marked Sunni or Shia) through trv_guide_set_status(), and never text the approver wrote or last changed (draftedBy, maker-checker); plain text is approved with programme.manage, one person. A change to approved text waits in draft beside it until approved; discard (programme.manage) drops it. programme.manage also writes parts and translations (trv_upsert_guide_part, trv_delete_guide_part, trv_save_guide_translation) and reads the editor and preview (trv_guide_editor, trv_guide_preview, also open to guide.religious.approve). New tables TripGuidePart, TripGuideTranslation, TravellerGuidePreference: row security, no policy, no browser grant — functions only. RLS TripGuideStep select narrowed: a reader without either permission sees approved, active steps for both traditions. The traveller reads through trv_guide() and sets their own language and tradition (trv_set_guide_preferences, own login only). §4.4b, §5.6, §6.5d. Test: supabase/tests/guide_languages.sql
2026-10-03 20261009110000 Bookings on the phone (LC-020, PRC-003, CXL-003, #559 step 4). No permission added, removed or regranted. app_reject_cancellation (SECURITY DEFINER; checks bookings.cancel.approve, the right the website's reject routes use; anon revoked; audited cancellation_rejected / passenger_cancellation_rejected). Everything else is an existing function under its existing right: update_booking (bookings.edit), request_booking_cancellation / request_passenger_cancellation (bookings.cancel), refund_preview / request_booking_refund (finance.payments.refund), approve_refund (finance.refunds.approve). §6.5d has the rows. Test: supabase/tests/app_bookings.sql.
2026-10-03 20261009100000 Group operations on the phone (INV-033, #559 step 3). No permission added, removed or regranted. app_link_group_flight, app_set_group_flight_pnr and app_unlink_group_flight (SECURITY DEFINER; each checks groups.edit, the right the website's Flights tab uses; anon revoked; audited flight_linked / pnr_updated / flight_unlinked). The phone's seat and room numbers are table updates under the existing bpf_update / bph_update (bookings.edit); Notify group is trv_post_notice (groups.edit). §6.5d has the rows. Test: supabase/tests/app_group_operations.sql.