Skip to content

Project access: who can claim against a project

Status: both steps built (departments; approver sets the project, option 2 below). Follows 2026-10-06-project-usage-design.md (the chargeable_by gate and members_only + project_member, both built).

Members-only is a hard refusal at line save: a new member who is not on the project cannot even submit, and finance must invite them first. Invites by hand do not scale, and the claimant (who has the receipt) is the person least likely to know the project’s name or code.

  1. The claimant is never blocked. The project stays optional on a claim.
  2. The control sits at approval, where someone with context decides.
  3. Access is granted by department, not person. Individual invites stay as the exception.
  4. Most projects stay open. Members-only is for the few that need it.
  5. Reuse the existing approval chain and per-project approver (account_approver grant). No request-access workflow, no lead inbox.

Every project has a default department (org_unit, table from migration 00018). It is the answer to “who normally works on this”, set when the project is created and editable later.

  • New table project_unit(org_id, account_id, unit_id), PK (account_id, unit_id), composite FKs to analytic_account(org_id,id) and org_unit(org_id,id), RLS org_isolation. A project may name several departments; the create dialog asks for one by default.
  • A member of a listed department (current member_position → org_position.unit_id, including child units) counts as a member of the project. The rule is stored, never expanded into project_member, so a new joiner gets access and a leaver loses it with no maintenance.
  • analytic.MemberProjects and CheckMember read explicit members UNION department matches. Both stay single indexed reads per source (project_member_by_member; project_unit by unit).
  • Existing projects: no department set, behaviour unchanged. Setting one is opt-in per project; nothing is backfilled.
  • Open projects ignore the department (everyone can already use them). It still feeds the claim form’s “My projects” list ordering.

Migration 01122 project_unit; analytic.SetUnits/ListUnits; MemberProjects and CheckMember read invited members UNION department matches (a listed unit’s path is a prefix of the member’s current seat’s unit path, so child units are included); GET/PUT /accounting/analytic-accounts/:id/units (accounting.account.manage); a Departments picker in the project dialog under “Only invited members”. Tests: TestADepartmentOpensAMembersOnlyProject (joiner gains, leaver loses, nothing copied into project_member), TestProjectUnitsOverHTTP, a web test. Seat dates use CURRENT_DATE (a seat starting tomorrow counts tomorrow).

Built: approver assigns the project (step 1, option 2)

Section titled “Built: approver assigns the project (step 1, option 2)”

Migration 01123 lets expense_claim_line.shares (and updated_at) change on a non-draft line, only when the transaction set app.line_project_edit = 'on' and every other column is identical (so Ruling R16 holds for amounts, dates, categories and documents). expenses.AssignLineProject is the sole caller; workflow.ReviseShares is the gate: the claim must be pending and not at final approval, the actor must be able to take a forward step now and must not be the claimant, and no workflow_signoff may exist on the instance (conservative: any, even from an earlier round; the claimant can send back and revise). The project’s chargeable_by still applies; membership is waived; “add the claimant” joins them when the project is members-only. Endpoint POST /expenses/claims/:id/lines/:lineId/project (expenses.claim.transition opens it, workflow authority decides); GET /expenses/analytic-options?claim_id= shows a reviewer who may assign every project, members-only included, and anyone else the ordinary list. A “Set project” picker appears on each line of the claim drawer. Tests: platform (claimant refused, reviewer assigns, amounts frozen, closed project refused, final approval refused), HTTP, web. Not covered by a test: the “already signed” refusal (a plain count of workflow_signoff).

Original finding and options (kept for the record)

Section titled “Original finding and options (kept for the record)”

expense_claim_line_draft_guard (migration 00072, Ruling R16, spec 6.4) rejects any insert or update of a claim line unless the claim’s workflow state is draft, and workflow.SetShares is draft-only for the same reason (“an approval is given on these shares”). So “approver sets the project on a pending line” is not a code addition; it is a deliberate carve-out in a statutory invariant: what the approver approved must be what was submitted. Options, safest first:

  1. Send back with a reason. The approver rejects with “assign a project” and the claimant revises. Exists today. Slow for the claimant, but the invariant is untouched.
  2. Narrow carve-out. The trigger allows a change to expense_claim_line.shares alone, only through a dedicated function that checks the actor may act on the current step, refuses once any workflow_signoff exists on the instance, re-sets instance shares, and audits before/after. Every other line column stays locked.
  3. Leave blank, assign at posting. Finance sets the analytic distribution when the accrual posts. Needs a posting-side change and weakens project approval (the project approver would not see the project).

Recommendation: 2, because it fixes the new-member case without reopening amounts. It is not built because it amends Ruling R16 and needs the owner’s decision.

Today lines are editable only by the claimant while the claim is a draft (lockDraftForEdit), so a wrong or missing project means reject and revise.

New action: set project on a line, allowed to the actor who may act on the claim’s current step, while the claim is pending.

  • Re-runs the project’s chargeable_by check for expense_claim.
  • Skips the membership check: the approver is the authority.
  • Optional tick “also add the claimant to this project” writes project_member.
  • Writes an audit row (old and new project, actor).
  • Re-sets the instance shares (workflow.SetShares) so the project-approver step routes to the assigned project.

Open point to prove in a test before building: SetShares is draft-only (“an approval is given on these shares”). Changing shares on a pending instance must not invalidate sign-offs already given. Either re-route and void only the affected sign-offs, or restrict the action to a step before any project sign-off. Decide from the test, not by assumption.

A blank project with a project-approver step turned on leaves an unallocated share nobody can sign (GapUnallocated). Rule: the normal approver assigns the project first; the project step runs afterward.

  1. Setup (finance/admin): projects open by default; restricted ones get members_only, a default department and a project approver.
  2. Claimant opens a claim. The picker offers open projects plus those they are on (explicit or by department).
  3. They pick one, or leave it blank with a note, and submit. Never blocked.
  4. The normal approver (manager or finance) sets or corrects the project.
  5. System re-checks chargeable_by, waives membership, re-routes to the project’s approver if one is assigned.
  6. Project approver approves (existing step).
  7. Posting is unchanged and never blocked by membership (ledger.Post is not touched).
  8. Optionally the claimant is added to the project so their next claim needs no help.

No clear-out needed. Workflow definitions are versioned and in-flight claims are pinned to their version (workflow_instance.definition_id). New tables are additive with safe defaults. The approver action only applies to pending claims; approved and paid claims and the posted ledger are never rewritten. Take a backup before running the migration on real data.

  • Department rules replace per-person lists: cost is rows per project, not per member.
  • Reads are index-only per source; the picker never loads the whole project list.
  • Membership only grows through explicit invites or approver ticks, so lists stay accurate.
  1. Approver sets project on a line, including the re-routing test.
  2. project_unit plus department-aware MemberProjects/CheckMember, and the department field in the project dialog.
  3. Only if people are still stuck: request-access or a lead inbox.
  • Whether projects with no department fall back to “everyone” or stay as today (explicit members only). Proposal: as today.
  • Whether to make the project axis required.
  • Reporting lines (route to the claimant’s manager) are not stored today.