Approval by project — design
Status: approved in conversation 2026-09-24. Revision 2 (same day, approved in conversation): each project chooses the approver of each project-approval step, and the approver can be anyone a workflow grant can name. Decisions changed by revision 2 are marked (r2). Revision 3 (2026-09-25, approved in conversation): a project’s cell for a step holds amount bands, each naming one or more approvers, any one of whom may sign. Decisions changed by revision 3 are marked (r3). Extends: architecture design §5 (workflow engine), §5.4 (thresholds, grantee kinds), §10.2 (analytic axes), §11A.4 (traceability); org-structure design §5.4–5.5 (resolution, persisted approver sets). Roadmap: Phase 2, Approval by project.
1. Problem
Section titled “1. Problem”Every expense claim in an organization runs one workflow, and every approver it names is found from the claimant’s place in the org chart (superior, unit head, levels up, a position, a named member) and the claim’s amount. Nothing about what the money was spent on can choose an approver. An organization that runs projects wants each project to say who approves what is charged to it, step by step:
| Step | Project A | Project B |
|---|---|---|
| Review | Ali (a person) | Claimant’s superior |
| Approve | Director position | Mei Ling |
| Finance | Finance team | Finance team |
The steps are the same for every project; the people differ. A claim is not tied to one
project. Each line carries shares — a split across analytic accounts
(expense_claim_line.shares, analytic.Share) — so one claim can charge project A in full
on one line and split another 60/40 across A and B.
2. Decisions
Section titled “2. Decisions”Numbered so the plan and reviews can cite them.
- P1 — Every affected project’s approver signs off their own share. A step that uses the rule does not advance until the approver of each project the claim charges has approved; each sees and approves that project’s share. Why: §11A.4 — no posted figure without a path back to the person who approved it. “Any one of them” or “the largest share decides” would post project B’s share with no approval by B’s owner. Mainstream expense practice (budget-holder / cost-object approval) is the same. Rejected alternative: one project per claim — equally compliant, but forces staff to split real multi-project spend into several claims to suit the software.
- P2 — Built on analytic axes, not on “project”. The rule names an axis; project is one
seeded axis. Cost centre, or a customer’s own axis, works unchanged. Costs nothing extra:
project is already just an
analytic_axisrow. - P3 — One workflow per organization still, and the same steps for every project. Adding a project means assigning its approvers, never publishing a workflow. Rejected alternative (r2): a workflow per project — it would force one project per claim (see P1) and a published workflow per project.
- P4 (r2) — Approvers are assigned per analytic account per step, and can be anyone a
grant can name: a named member, a position, the claimant’s superior, the claimant’s
unit head, or N levels up. Assignments carry
valid_from/valid_toand are never edited in place, mirroringmember_position, so a past decision can be explained. Ending an assignment (so the step’s default applies again) is itself a dated, audited row change, never a delete. - P5 (r2) — Fallback, in order: the account’s own assignment for the step → its
nearest ancestor’s (walking
analytic_account.parent_id) → the step’s default approver, which is the step’s ordinary grants in the workflow. In the table above, Finance team is set once as Finance’s default; nobody assigns it per project. - P6 (r3) — A step using the rule may carry default grants, with no amount floor. A
project-approval step is marked by an
account_approvergrant naming the axis; its other grants on that state are the default. Neither the marker nor a default grant carriesmin_amount; seniority by amount on such a step lives in the projects’ own cells (P16).all_statesgrants are not consulted on such a step (Plan 11 Ruling R5). Enforced when a definition is validated. - P7 — Shares freeze at submission, as the amount does (Ruling 15). The claim reports its per-account totals to the engine while it is a draft; they cannot move under an approval.
- P8 — Any one required approver may reject. The claim takes the step’s existing reject edge. Sign-offs already given stay on record (append-only); a resubmitted claim needs every sign-off again.
- P9 (r2) — A claimant never approves their own share. The claimant is removed from
whoever the fallback chain (P5) yields; if nobody is left, the next link of the chain
applies, and after the default, the claimant’s superior (org chart). Project managers
must be able to claim against their own project. If nothing yields someone other than
the claimant, the gap is
only_the_claimantand submission is refused. - P10 (r2) — Refused at submission, as today (Ruling 13): a share of the amount with no
account on the step’s axis (
unallocated, naming the axis), or an account for which neither an assignment nor the step’s default yields anyone (no_account_approver, naming the account). The editor’s routing preview shows both. - P11 — Delegation and SLA escalation apply per sign-off, exactly as they apply to a band member today (delegation: the delegate acts on the delegator’s authority; escalation, Ruling 10: after the state’s SLA the next filled seat above a position-assigned approver may sign that share).
- P12 — Expense claims only in version 1. Only a kind whose module reports shares to the engine may use the rule; petty cash and advances do not, and validation refuses the rule on their definitions.
- P13 — Nothing downstream changes. Posting reacts to semantic tags, not to who approved. Existing definitions and in-flight claims behave exactly as before; only a newly published version that uses the rule changes routing, and claims stay pinned to the version they started on.
- P14 — Assigning an approver is an authorization change, guarded like publishing a
grant (
workflow.definition.publish), not like editing the chart. “A claimant never picks their own approver” extends to “whoever maintains projects does not pick who approves them” unless they also hold the publishing permission. - P15 (r2) — Project approvers apply only to approval steps: a
pending-semantic state entered by atarget-gated edge (Plan 11 Ruling R4). Posting and payment steps (accrual, payout) keep their ordinary grants for every project. - P16 (r3) — A project’s cell for a step is a list of amount bands. Each band has a starting amount and one or more approvers (any kind P4 allows); any one of them may sign. The band that applies is the one with the highest starting amount at or below the project’s share of the claim, not the claim total: each approver answers for their own project’s money (P1). Bands do not overlap — for from RM 0: Ali; from RM 2,000: Mei Ling, a RM 1,500 share goes to Ali only and a RM 3,000 share to Mei Ling only. The first band must start at RM 0, starting amounts strictly increase, and an approver appears at most once in a band; a cell breaking any of these is refused when saved, so a cell never leaves an amount uncovered. Why: organizations set approval limits per person; a gap is almost always a setup mistake, cheaper to refuse at the grid than to discover on a stuck claim. Rejected: falling back to the parent or default for an uncovered amount (hides the mistake), and choosing the band by the claim total (stricter than what the approver actually approves).
3. Model
Section titled “3. Model”3.1 Grantee kind
Section titled “3.1 Grantee kind”grantee_kind gains account_approver, with grantee_axis_id (new nullable column on
workflow_gate_grant, required exactly for this kind; levels_up, member and position
columns null). It is the step’s marker: “projects on this axis choose this step’s
approver”. It is never all_states and never carries min_amount (P6). Its on_vacancy
and escalation settings apply to every project’s assigned approver on the step
(r2); the step’s default grants use their own (§4.1).
3.2 Approver assignment — analytic_account_approver (r2)
Section titled “3.2 Approver assignment — analytic_account_approver (r2)”| Column | Notes |
|---|---|
org_id, id |
tenant key; ENABLE + FORCE ROW LEVEL SECURITY, policy org_isolation |
analytic_account_id |
FK (org_id, id) to analytic_account |
definition_kind |
'expense_claim' in v1 (P12); the workflow the step belongs to |
state_key |
the step, by its key in that kind’s graph; keys survive republishing |
grantee_kind |
'member', 'position', 'superior', 'unit_head', 'levels_up' — strings |
position_id / member_id / levels_up |
exactly the one the kind needs, as on workflow_gate_grant |
min_amount (r3) |
NUMERIC(19,4) NOT NULL DEFAULT 0, >= 0; the band’s starting amount (P16) |
valid_from, valid_to, withdrawn_at |
dated; at most one live row per account, step, band and approver per day (exclusion constraint) (r3) |
created_by_member_id, created_at |
who assigned it |
One row is one approver in one band (r3); a cell is the set of live rows for an
account and step. A cell is saved whole: in one transaction every live row of the cell
is closed (valid_to) and the new set inserted, after the P16 checks pass — the database
cannot express “the set starts at 0”, so the save enforces it, with a test. Ending a cell
closes its rows without successors; a row that started today is voided with
withdrawn_at instead. Rows are never updated otherwise and never deleted. Every write is audited. An
assignment for a step the published graph no longer has is inert; it is shown as such, not
removed.
3.3 Shares on the instance — workflow_instance_share
Section titled “3.3 Shares on the instance — workflow_instance_share”The claim’s frozen per-account totals, one row per (instance, axis, account), amount
NUMERIC(19,4). Written by workflow.SetShares(ctx, tx, c, instanceID, []Share) — the
sibling of SetAmount, with the same rule: refused unless the instance is a draft. The
expenses module calls it wherever it calls SetAmount today (expenses/line.go), summing
each line’s base amount by its shares per axis. Lines with no account on an axis contribute
to that axis’s unallocated total, which is what P10’s refusal reads.
The workflow package stays generic: it never reads expense_claim_line.
3.4 Sign-offs — workflow_signoff
Section titled “3.4 Sign-offs — workflow_signoff”A step using the rule is entered by one transition, as today, but that transition waits for several approvals. Each approval is a sign-off row:
| Column | Notes |
|---|---|
org_id, id, instance_id |
tenant key, RLS as above |
from_key, to_key |
the edge being signed |
entry_decision_id |
the decision that entered from_key — scopes sign-offs to this visit, so a resubmission starts clean (P8) |
analytic_account_id, share_amount, currency |
what was approved |
actor_member_id, authority_member_id, authority_via, delegation_id |
same evidentiary fields as workflow_decision |
eligible_member_ids |
the resolved approver set, persisted (org-structure §5.5) |
ip, request_id, decided_at |
as workflow_decision |
Append-only: the app role is granted INSERT, SELECT only, and the table joins the
retention drift test. Unique on (org_id, instance_id, entry_decision_id, to_key, analytic_account_id).
When the last required sign-off lands, the same transaction performs the transition exactly
as Transition does today — decision row, posting dispatch, audit — and the decision row
records the step’s resolution as the list of sign-off ids.
4. Behaviour
Section titled “4. Behaviour”4.1 Resolution (r2)
Section titled “4.1 Resolution (r2)”Route and Authorize receive the instance’s shares through Subject. For a step marked
by an account_approver grant, resolution yields one required sign-off per account
with a non-zero share on the grant’s axis, each with its own resolved member set. The
candidate approvers are tried in the P5 order:
- The account’s live cell for this step on the business date, then each ancestor’s. From a cell, only the band P16 selects for this account’s share is a candidate (r3); an ancestor’s cell is banded by the same share.
- The step’s default grants (its other grants on that state).
Each candidate is resolved exactly as a grant of that kind is today (resolveOne: a
member; a position’s holders; the claimant’s superior, unit head or N levels up). Vacancy
(r2): an assignment follows the marker’s on_vacancy and escalation bound; a default
grant follows its own. Inactive members are dropped (activeOnly) and the
claimant is removed (P9). A candidate that yields nobody else moves on to the next; after
the default, the claimant’s superior. If nothing yields anyone: no_account_approver (no
candidate existed) or only_the_claimant. A candidate that exists but resolves to a gap
(a retired position, a claimant with no superior) reports that gap rather than silently
falling through, so an administrator sees the broken assignment.
A band (r3) is one candidate: its approvers are resolved each as above and their members united, and any one of the union may sign. An approver in the band who resolves to a gap is dropped from the union while others remain; only a band that yields nobody (after P9) reports a gap, the first approver’s, and stops the chain as a single assignment does.
4.2 Acting
Section titled “4.2 Acting”AvailableTransitions shows the approve action to anyone eligible for at least one
outstanding sign-off. One click signs every outstanding share the actor is eligible for
— one row each. An actor who has signed everything they can sees the action disabled, with
refusal code already_signed. The claim page lists each project, its share, its approver
and whether it is signed.
A reject from the waiting state is open to anyone eligible for any sign-off on the step (P8) and follows the existing edge and gate.
4.3 Refusals
Section titled “4.3 Refusals”New gap reasons, surfaced through the existing not_routable refusal and routing preview:
unallocated (axis), no_account_approver (account). only_the_claimant is reused for
P9’s dead end.
4.4 Validation (publish time) (r2)
Section titled “4.4 Validation (publish time) (r2)”account_approveronly on kinds that report shares (P12):expense_claimin v1.- Only on a
pendingstate entered by atarget-gated edge (P15); at most one per state. - Neither the marker nor the state’s default grants carry a floor (P6).
- The axis exists, is active, and applies to expenses.
- Publishing a version that drops a step some assignments name is allowed; those assignments become inert (3.2).
5. API (r2)
Section titled “5. API (r2)”Declared in api/openapi.yaml first, as every endpoint:
GET /accounting/analytic-accounts/{id}/approvers— the account’s assignments for every step, history included (workflow.definition.read).POST /accounting/analytic-accounts/{id}/approvers— save the whole cell of one step (r3):{definition_kind, state_key, bands: [{min_amount, approvers: [{grantee kind and reference}]}]}, amounts as strings, closing the live cell (3.2); refused with a field error when P16’s checks fail. And a way to end the live cell of a step so the default applies again. Both needworkflow.definition.publish, notaccounting.account.manage(which guards creating and renaming analytic accounts today). An assignment changes who may approve exactly as publishing a grant does, so it sits behind the same permission (P14).- Every assignment row carries its band’s
min_amount(r3) wherever it is read; a client groups a cell’s rows by it. GET /accounting/analytic-approvers— every account’s assignments in force today, and the project-approval steps of the published expense-claim workflow with their defaults, for the projects screen (workflow.definition.read).GET /expenses/analytic-options— the active axes applying to expenses and their accounts, for the claim form (expenses.claim.read).- Workflow grant schema:
grantee_kind: account_approverwithgrantee_axis_id. - Claim detail:
signoffs[]per pending step — account, name, share, eligible approvers, signed-by and when. - Available transitions: refusal code
already_signed; routing preview gapsunallocated,no_account_approver. - Approvals inbox rows for a sign-off step carry the actor’s own share amount.
6. Screens (r2)
Section titled “6. Screens (r2)”- Projects and approvers (under Accounting settings): for each axis a grid, projects as rows, project-approval steps as columns. A cell shows the project’s approver for that step, or “Default: …” (the step’s default) when the project has none. A banded cell reads as a summary, e.g. “Ali · from RM 2,000: Mei Ling” (r3). Clicking a cell opens a dialog of band rows — a starting amount (fixed at RM 0 on the first) and one or more approvers of any kind (member, position, superior, unit head, N levels up) — with “Add band”, or ends the cell to return to the default; a cell’s history is one click away. Names come from translation rows.
- Workflow editor: on an approval step, “Projects on axis choose this step’s approver”, with the step’s other grants labelled as the default; the P6 rule shown inline; routing preview lists one row per account with its approver or gap.
- Claim detail: a sign-off panel on a waiting step, listing every member who may sign each share.
- Approvals inbox: the actor’s share, not only the claim total.
- Claim form — charge to (Plan 11 Rulings R1–R3): a per-axis picker (one account at
100% by default, split into several with percentages), fed by
GET /expenses/analytic-optionsbehindexpenses.claim.read. Theunallocatedrefusal names the axis; the form marks that axis on the line.
All three locales (en, ms, zh); money as strings end to end.
7. Testing
Section titled “7. Testing”Beyond unit coverage of each piece, these are the load-bearing tests, each written presence-before-absence and seen failing once:
- A project’s approver cannot sign another project’s share: first show the same approver can sign their own, then that the other share stays outstanding and their attempt is refused.
- Two projects get different approvers on the same step, and a project with no assignment gets the step’s default (r2): show the default would apply to both without the assignment, then that the assigned project routes to its own approver.
- A project’s assignment is per step (r2): its Review approver is not its Approve approver.
- A claimant who is their project’s approver never signs their own share: show the share would resolve to them without P9, then that it resolves to the next link.
- The transition happens on the last sign-off and not before: show the state unchanged after N−1, changed after N, and exactly one decision row.
- Shares cannot change after submission: show
SetSharessucceeds on the draft, then refused after submit. - A resubmission requires every sign-off again: sign-offs from the earlier visit exist and are not counted.
workflow_signoffandanalytic_account_approverare covered by the tenant-isolation and retention drift tests.- Existing definitions route identically: the workflow package’s current tests pass unchanged.
- One Playwright flow: a claim split across two projects with different approvers on two steps waits for both approvers at each step, then moves on.
- (r3) Bands pick by the share, at the boundary: a share one sen below a band’s start routes to the band beneath, a share at the start routes to the band — show both.
- (r3) Any one of a band’s approvers completes the share: two approvers are eligible, one signs and the share is signed; the other is then no longer offered the approve action for it, and a direct attempt is refused.
- (r3) A cell that does not start at RM 0 is refused: first save a cell that does (presence), then show the gapped one refused and the live cell unchanged.
- (r3) An account with no cell uses its parent’s bands, banded by its own share.
- (r3) Playwright: one project with two bands; a small claim waits for the lower-band approver, a large one for the upper.
8. Out of scope
Section titled “8. Out of scope”- A different set of steps per project (P3).
- Petty cash, advances and future AP documents (P12) — the seam (
SetShares) is generic, so adding a kind later is its module calling it. - Amount floors on the marker or a step’s default grants (P6); amounts are banded only in a project’s cell (P16).
- Budgets and budget-vs-actual (separate roadmap item) — sign-off does not check remaining budget.
- Creating analytic accounts from the web app (the API exists behind
accounting.account.manage; the screen is a separate roadmap item).