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).
Problem
Section titled “Problem”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.
Principles
Section titled “Principles”- The claimant is never blocked. The project stays optional on a claim.
- The control sits at approval, where someone with context decides.
- Access is granted by department, not person. Individual invites stay as the exception.
- Most projects stay open. Members-only is for the few that need it.
- Reuse the existing approval chain and per-project approver
(
account_approvergrant). No request-access workflow, no lead inbox.
Default department per project
Section titled “Default department per project”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 toanalytic_account(org_id,id)andorg_unit(org_id,id), RLSorg_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 intoproject_member, so a new joiner gets access and a leaver loses it with no maintenance. analytic.MemberProjectsandCheckMemberread explicit members UNION department matches. Both stay single indexed reads per source (project_member_by_member;project_unitby 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.
Built: departments (step 2)
Section titled “Built: departments (step 2)”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)”Finding: the database forbids it today
Section titled “Finding: the database forbids it today”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:
- 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.
- Narrow carve-out. The trigger allows a change to
expense_claim_line.sharesalone, only through a dedicated function that checks the actor may act on the current step, refuses once anyworkflow_signoffexists on the instance, re-sets instance shares, and audits before/after. Every other line column stays locked. - 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.
Intended behaviour (if 2 is chosen)
Section titled “Intended behaviour (if 2 is chosen)”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_bycheck forexpense_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.
- Setup (finance/admin): projects open by default; restricted ones get
members_only, a default department and a project approver. - Claimant opens a claim. The picker offers open projects plus those they are on (explicit or by department).
- They pick one, or leave it blank with a note, and submit. Never blocked.
- The normal approver (manager or finance) sets or corrects the project.
- System re-checks
chargeable_by, waives membership, re-routes to the project’s approver if one is assigned. - Project approver approves (existing step).
- Posting is unchanged and never blocked by membership
(
ledger.Postis not touched). - Optionally the claimant is added to the project so their next claim needs no help.
Existing data
Section titled “Existing data”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.
Scalability
Section titled “Scalability”- 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.
Build order
Section titled “Build order”- Approver sets project on a line, including the re-routing test.
project_unitplus department-awareMemberProjects/CheckMember, and the department field in the project dialog.- Only if people are still stuck: request-access or a lead inbox.
Not built / not decided
Section titled “Not built / not decided”- 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.