Skip to content

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.

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.

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_axis row.
  • 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_to and are never edited in place, mirroring member_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_approver grant naming the axis; its other grants on that state are the default. Neither the marker nor a default grant carries min_amount; seniority by amount on such a step lives in the projects’ own cells (P16). all_states grants 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_claimant and 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 a target-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).

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.

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.

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:

  1. 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.
  2. 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.

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.

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.

  • account_approver only on kinds that report shares (P12): expense_claim in v1.
  • Only on a pending state entered by a target-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).

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 need workflow.definition.publish, not accounting.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_approver with grantee_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 gaps unallocated, no_account_approver.
  • Approvals inbox rows for a sign-off step carry the actor’s own share amount.
  • 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-options behind expenses.claim.read. The unallocated refusal names the axis; the form marks that axis on the line.

All three locales (en, ms, zh); money as strings end to end.

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 SetShares succeeds 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_signoff and analytic_account_approver are 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.
  • 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).