Plan 11 — Approval by project Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: An approval step of the expense-claim workflow can be decided by the approver of each analytic account (project, cost centre, or a customer’s own axis) the claim is charged to: every charged account’s approver signs off their own share, the step advances on the last sign-off, and a claimant never signs their own share — with one workflow per organization, per-account approvers assigned by an administrator, and nothing downstream of approval changing.
Architecture: A new grantee kind account_approver on workflow_gate_grant names an axis. The expense module reports the claim’s per-account totals to the engine (workflow.SetShares, sibling of SetAmount) while it is a draft. At a step carrying that grant, the engine resolves one required sign-off per charged account (assignment → parent account → claimant’s superior), records each approval as an append-only workflow_signoff row, and performs the ordinary transition — decision row, posting dispatch, audit — in the transaction that records the last one. Per-account approvers live in analytic_account_approver, dated and never edited in place. The web app gains a projects-and-approvers screen, a charge-to picker on the claim form, the new rule in the workflow editor, and a sign-off panel.
Tech Stack: Go 1.26 · Gin · pgx v5 · goose · oapi-codegen · testify · testcontainers Postgres 17 · React · TanStack Query/Router · Radix UI · i18next (en/ms/zh) · Vitest + MSW · Playwright
Spec: docs/superpowers/specs/2026-09-24-project-approval-design.md (decisions P1–P14), extending 2026-09-20-erp-architecture-design.md §5, §5.4, §10.2, §11A.4. Binding project rules: AGENTS.md.
Global Constraints
Section titled “Global Constraints”- A float never touches money, in any layer:
money.Amountin Go,NUMERIC(19,4)in Postgres, a string on the wire and in the web app (web/src/lib/money.tsonly; noNumber(,parseFloat,.toFixednear an amount). - Every new business table has
org_id,ENABLEandFORCE ROW LEVEL SECURITY, and a policy named exactlyorg_isolation(auto-checked byinternal/kernel/db/rlscoverage_test.go). - Every foreign key between tenant tables is composite on
(org_id, …)(TestEveryTenantForeignKeyIsCompositeOnOrgID), and every tenant query carries an explicitorg_id = $npredicate (TestEveryTenantQueryCarriesAnExplicitOrgPredicate) even though RLS would filter anyway. - Every constraint is named (
internal/kernel/db/constraintnames_test.go): no name ending_check,_keyor_excl. House names:<table>_tenant_idforUNIQUE (org_id, id),<table>_<what>_fk,<table>_<rule>for checks. - Migrations for this plan are numbered
00190–00209, and the range is registered ininternal/kernel/db/migrationranges_test.goin the same commit as the first migration. - Every new table is placed in exactly one list of
internal/kernel/db/retention_test.go(appendOnly,amendable,operational,unruled), with a comment saying why. - Vocabulary values are strings (
account_approver,position,member,already_signed,unallocated,no_account_approver), never integers. - The ledger and the decision log are append-only.
workflow_signoffisINSERT, SELECTonly forerp_app. accounting/and posting are untouched (P13): no posting rule, no journal code changes.- Existing seeded graphs are not edited (
internal/platform/workflow/seed.go,advances/graphs.go,pettycash/kinds.go). The workflow package’s existing tests pass unchanged — they are the proof that existing definitions route identically (P13). - Lock order (audit.go’s registry): inside one transaction, the instance lock and every row lock are taken before the transaction’s first
audit.Write. No new advisory-lock class is introduced. - Every request runs in a transaction, reads included: handlers use
h.deps.DB.InTx; errors are recorded with_ = c.Error(err), never written by hand. - Every route goes through
httpx.Protectedand is pinned in its module’sroutes_test.go. Endpoints are declared inapi/openapi.yamlfirst; generated code is regenerated withmake generate, never hand-edited. - Every user-facing string is an i18n key in
en,msandzh.msis statutory; do not leave it English. - Establish presence before asserting absence (AGENTS.md). Every test that asserts something is refused, missing or not counted first shows it would be allowed, present or counted without the mechanism. Watch each new guard fail once by breaking what it guards.
- Test runs: while iterating, only the packages you touched (
go test ./internal/platform/workflow/... -count=1,pnpm -C web test -- <path>). The one full run (go test -p 2 -count=1 ./...,pnpm -C web typecheck && pnpm -C web lint && pnpm -C web test,pnpm -C web e2e) happens once, in Task 14. - Runtime check for screens: each frontend task loads its screen against the dev stack at 390 px and 1280 px, light and dark, in
enandms, before it reports done. - Commits end with
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>.
Rulings
Section titled “Rulings”Made while planning, against the spec. Format: decision — why — cost if wrong.
- R1 — Claimants get a charge-to picker; the spec omitted it. The claim form today never sets
shares(the API accepts them, the UI has no field), and the analytic lists are readable only withaccounting.ledger.read, which claimants lack. So Task 11 adds a per-axis split picker to the claim form, and Task 8 addsGET /expenses/analytic-optionsbehindexpenses.claim.read. — Without it no claim could ever reach a project approver, and P10 would refuse every submission once a workflow uses the rule. — A picker nobody wanted: removable, it writes the existingsharesfield. - R2 — The picker supports splits. The wizard edits one line per claim, so a claim touching two projects exists in the UI only as one line split across them. Per axis: rows of account + percentage, one row at 100% by default. — P1 is otherwise unreachable from the UI. — Extra UI a customer never uses: the default single row behaves like a plain select.
- R3 —
unallocatednames the axis, not the line. The engine is generic and never readsexpense_claim_line; it knows only the unallocated total per axis. The claim form marks the lines missing that axis (it has the lines). — Keeps the module boundary (spec 3.3). — The refusal message is one step less specific in the API. - R4 — A sign-off grant is allowed only on a
pending-semantic state, entered by atarget-gated edge. — Accrual and payout states carry settlement and posting behaviour that a partial, non-moving approval must never interleave with. — A customer who wants project sign-off at the accrual step must add a pending step before it. - R5 —
all_statesgrants do not apply on a sign-off step. P6 says the step holds only the account rule;GrantsOnwould add everyall_statesgrant. The sign-off path reads only the state’s ownaccount_approvergrant.all_statesholders keep standing everywhere else. — P6 literally. — An all-states administrator cannot sign a project’s share; they fix the approver assignment instead. - R6 —
workflow_instance_shareisoperational(DELETE kept).SetSharesreplaces the set while the claim is a draft; the frozen evidence of what was approved is on eachworkflow_signoffrow (share_amount). Go refusesSetSharesoutside draft, asSetAmountdoes. — Replace-on-save is the simplest correct write. — If an auditor wants the shares as submitted in their own table, add a trigger refusing DELETE once the instance leaves draft. - R7 —
analytic_account_approvermirrorsmember_position: dated rows,amendable(UPDATE kept for closing, DELETE revoked), awithdrawn_atvoid for a same-day correction, one live row per account by exclusion constraint. Every new assignment starts today. — Spec P4 and the 00023 precedent. — None known. - R8 — The approvers endpoints live in the accounting module (URL
/accounting/analytic-accounts/{id}/approvers,/accounting/analytic-approvers) and are gated on the literal permission stringsworkflow.definition.read/workflow.definition.publish(P14), declared as local constants with a comment; the accounting module does not import the workflow module. — URL follows the resource; the permission follows the power it confers. — A reader might expectaccounting.*; the routes test pins it. - R9 — Account names travel in the engine’s sign-off rows (
account_code,account_name, the record-bearing name, as the analytic account DTO already returns it). — The routing preview, the sign-off panel and the inbox each need the name, and their readers hold different permissions; one join in the engine beats three permission-dependent lookups. — Names are not localized, which is already true of analytic accounts today. - R10 — “Your share” in the approvals inbox is computed by asking the engine per listed claim (
AvailableTransitions), exactly as the queue already does to decide membership; bounded by the queue’s existing 200-row ceiling. — No second authorization model. — Twice the engine calls per queue load; acceptable at 200. - R11 — The routing preview shows every active account on a sign-off step’s axis (
workflow.PreviewShares), each with its resolved approver or gap. — An administrator’s question is “which projects have nobody”, not one hypothetical claim’s. — Long lists for organizations with many projects.
File Structure
Section titled “File Structure”db/migrations/00190_project_approval.sql (T1)internal/kernel/db/migrationranges_test.go (T1) + Plan 11 rangeinternal/kernel/db/retention_test.go (T1) + 3 tablesinternal/platform/workflow/migration_test.go (T1) new: 00190 up/downsqlc.yaml, internal/gen/sqlc/models.go (T1) overrides + regenerateinternal/platform/accounting/analytic/approver.go (T2) newinternal/platform/accounting/analytic/approver_test.go (T2) newinternal/platform/accounting/analytic/plan.go (T7) + Allocateinternal/platform/workflow/model.go (T3) kind, AxisID, SignoffGrantinternal/platform/workflow/validate.go (T3) account_approver rulesinternal/platform/workflow/store.go (T3) persist/load axis; DB-aware axis checksinternal/platform/workflow/validate_test.go, store_test.go (T3)internal/platform/workflow/shares.go (T4) new: Share, SetShares, Sharesinternal/platform/workflow/shares_test.go (T4) newinternal/platform/workflow/instance.go (T4, T6) subjectOf, Transition, AvailableTransitionsinternal/platform/workflow/signoff.go (T5, T6) new: resolution, recording, statusinternal/platform/workflow/signoff_test.go (T5, T6) newinternal/platform/workflow/authorize.go (T5, T6) Route(shares), authorizeInBand, sign-off branchinternal/platform/workflow/resolve.go (T5) gap constants, Gap fieldsinternal/platform/workflow/workflowtest/fixture.go (T5) Axis/Account/Approver helpersinternal/platform/expenses/line.go (T7) reprice → SetSharesinternal/platform/expenses/transition.go (T7) pending sign-off pathinternal/platform/expenses/claim.go (T7) YourSharesinternal/platform/expenses/*_test.go (T7)api/openapi.yaml (T8)internal/gen/api/api.gen.go, web/src/lib/api/schema.d.ts (T8) regeneratedinternal/modules/accounting/approver_handlers.go (T8) newinternal/modules/accounting/routes.go, routes_test.go (T8)internal/modules/expenses/claim_handlers.go (T9) signoffs, your_shareinternal/modules/expenses/analytic_handlers.go (T9) new: analytic optionsinternal/modules/expenses/routes.go, routes_test.go (T9)internal/modules/workflow/drafts.go (T9) preview sharesweb/src/features/accounting/approvers/* (T10) new screenweb/src/app/shell/nav.ts, web/src/app/routes.ts (T10) appendweb/src/features/claims/wizard/ChargeTo.tsx + wiring (T11)web/src/features/workflow/GrantsTable.tsx, graphModel.ts, RoutingPreview.tsx (T12)web/src/features/claims/detail/SignoffPanel.tsx + wiring (T13)web/src/features/approvals/* (T13) your shareweb/src/lib/i18n/locales/{en,ms,zh}/*.json (T10–T13)web/e2e/project-approval.spec.ts, web/e2e/seed.ts (T14)docs/ROADMAP.md, docs/DEPLOY_NOTES.md (T14)Order: T1 → T2 → T3 → T4 → T5 → T6 → T7 → T8 → T9 → (T10, T11, T12, T13 in parallel; they touch disjoint files except the i18n locale files, where each task only adds its own keys to its own namespace) → T14.
Task 1: Schema — grant kind, approvers, shares, sign-offs
Section titled “Task 1: Schema — grant kind, approvers, shares, sign-offs”Files:
- Create:
db/migrations/00190_project_approval.sql - Create:
internal/platform/workflow/migration_test.go - Modify:
internal/kernel/db/migrationranges_test.go(add{190, 209, "Plan 11"}after the Plan 10 row) - Modify:
internal/kernel/db/retention_test.go(three tables, below) - Modify:
sqlc.yaml(money overrides forworkflow_instance_share.amount,workflow_signoff.share_amount) and regenerateinternal/gen/sqlc/models.go
Interfaces:
-
Produces: tables
analytic_account_approver,workflow_instance_share,workflow_signoff; columnworkflow_gate_grant.grantee_axis_id; grantee kind'account_approver'. -
Step 1: Find the generated name of the grantee-kind check.
Run: docker exec erp-dev-pg psql -U postgres -d erp -At -c "select conname from pg_constraint where conrelid='workflow_gate_grant'::regclass and pg_get_constraintdef(oid) like '%grantee_kind IN%' and pg_get_constraintdef(oid) like '%levels_up''%'"
Expected: workflow_gate_grant_grantee_kind_check. If it differs, use the printed name in the migration’s DROP CONSTRAINT (Up) and re-add with that name (Down).
- Step 2: Write the failing migration test —
internal/platform/workflow/migration_test.go:
package workflow_test
import ( "context" "database/sql" "testing"
"github.com/pressly/goose/v3" "github.com/stretchr/testify/require"
"github.com/mcom/erp/internal/kernel/db/testdb")
// TestMigration00190AddsProjectApproval: before 00190 none of its objects// exist; after Up they all do and refuse their malformed rows; Down removes// them; Up again reapplies.func TestMigration00190AddsProjectApproval(t *testing.T) { h := testdb.StartAt(t, 170) ctx := context.Background() exists := func(table string) bool { var ok bool require.NoError(t, h.Admin.QueryRow(ctx, `SELECT to_regclass($1) IS NOT NULL`, table).Scan(&ok)) return ok } column := func() bool { var ok bool require.NoError(t, h.Admin.QueryRow(ctx, ` SELECT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name = 'workflow_gate_grant' AND column_name = 'grantee_axis_id')`).Scan(&ok)) return ok } tables := []string{"analytic_account_approver", "workflow_instance_share", "workflow_signoff"} for _, tb := range tables { require.False(t, exists(tb), "ABSENCE before 00190: %s", tb) } require.False(t, column())
conn, err := sql.Open("pgx", h.AdminDSN) require.NoError(t, err) defer conn.Close() require.NoError(t, goose.SetDialect("postgres")) require.NoError(t, goose.UpTo(conn, testdb.MigrationsDir(t), 190, goose.WithAllowMissing()))
for _, tb := range tables { require.True(t, exists(tb), "PRESENCE after Up: %s", tb) } require.True(t, column()) var rls, force bool for _, tb := range tables { require.NoError(t, h.Admin.QueryRow(ctx, `SELECT relrowsecurity, relforcerowsecurity FROM pg_class WHERE relname = $1`, tb).Scan(&rls, &force)) require.True(t, rls && force, "%s is tenant-isolated", tb) } var appPrivs []string rows, err := h.Admin.Query(ctx, ` SELECT privilege_type FROM information_schema.role_table_grants WHERE grantee = 'erp_app' AND table_name = 'workflow_signoff' ORDER BY 1`) require.NoError(t, err) for rows.Next() { var p string require.NoError(t, rows.Scan(&p)) appPrivs = append(appPrivs, p) } require.NoError(t, rows.Err()) require.Equal(t, []string{"INSERT", "SELECT"}, appPrivs, "sign-offs are append-only")
require.NoError(t, goose.Down(conn, testdb.MigrationsDir(t))) for _, tb := range tables { require.False(t, exists(tb), "Down removes %s", tb) } require.False(t, column()) require.NoError(t, goose.UpTo(conn, testdb.MigrationsDir(t), 190, goose.WithAllowMissing())) require.True(t, column(), "Up after Down reapplies")}- Step 3: Run and watch it fail.
Run: go test ./internal/platform/workflow/ -run TestMigration00190 -count=1
Expected: FAIL — goose finds no migration 190 (UpTo succeeds without it, then exists is false).
- Step 4: Write the migration —
db/migrations/00190_project_approval.sql:
-- +goose Up
-- Plan 11 — approval by project (spec 2026-09-24-project-approval-design.md).
-- 3.1 A grant can name an axis: "the approver of the claim's account on it".-- The kind list is widened by drop-and-re-add; a CHECK cannot be altered.ALTER TABLE workflow_gate_grant ADD COLUMN grantee_axis_id UUID;ALTER TABLE workflow_gate_grant DROP CONSTRAINT workflow_gate_grant_grantee_kind_check;ALTER TABLE workflow_gate_grant ADD CONSTRAINT workflow_gate_grant_grantee_kind_known CHECK (grantee_kind IN ('member', 'superior', 'position', 'unit_head', 'levels_up', 'account_approver'));ALTER TABLE workflow_gate_grant ADD CONSTRAINT workflow_gate_grant_axis_matches_kind CHECK ((grantee_kind = 'account_approver') = (grantee_axis_id IS NOT NULL));-- P6 and Ruling R5: a sign-off grant is one state's, and carries no floor.-- validate.go refuses both first; this is the backstop behind it.ALTER TABLE workflow_gate_grant ADD CONSTRAINT workflow_gate_grant_account_approver_shape CHECK (grantee_kind <> 'account_approver' OR (NOT all_states AND min_amount IS NULL));ALTER TABLE workflow_gate_grant ADD CONSTRAINT workflow_gate_grant_axis_fk FOREIGN KEY (org_id, grantee_axis_id) REFERENCES analytic_axis (org_id, id);
-- 3.2 Who approves what is charged to an analytic account. Dated and never-- edited in place, like member_position (00018, 00023): a change closes the-- live row (valid_to) and inserts a new one; a row that started today is-- voided (withdrawn_at) instead, because valid_to must follow valid_from.CREATE TABLE analytic_account_approver ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), analytic_account_id UUID NOT NULL, grantee_kind TEXT NOT NULL CONSTRAINT analytic_account_approver_kind_known CHECK (grantee_kind IN ('position', 'member')), position_id UUID, member_id UUID, valid_from DATE NOT NULL, valid_to DATE, withdrawn_at TIMESTAMPTZ, created_by_member_id UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT analytic_account_approver_ref_matches_kind CHECK ( ((grantee_kind = 'position') = (position_id IS NOT NULL)) AND ((grantee_kind = 'member') = (member_id IS NOT NULL))), CONSTRAINT analytic_account_approver_dates_ordered CHECK (valid_to IS NULL OR valid_to > valid_from), CONSTRAINT analytic_account_approver_tenant_id UNIQUE (org_id, id), CONSTRAINT analytic_account_approver_account_fk FOREIGN KEY (org_id, analytic_account_id) REFERENCES analytic_account (org_id, id), CONSTRAINT analytic_account_approver_position_fk FOREIGN KEY (org_id, position_id) REFERENCES org_position (org_id, id), CONSTRAINT analytic_account_approver_member_fk FOREIGN KEY (org_id, member_id) REFERENCES organization_member (org_id, id), CONSTRAINT analytic_account_approver_creator_fk FOREIGN KEY (org_id, created_by_member_id) REFERENCES organization_member (org_id, id), -- One live approver per account per day. btree_gist exists since 00018. CONSTRAINT analytic_account_approver_one_live EXCLUDE USING gist ( analytic_account_id WITH =, daterange(valid_from, valid_to, '[)') WITH && ) WHERE (withdrawn_at IS NULL));CREATE INDEX analytic_account_approver_account_idx ON analytic_account_approver (org_id, analytic_account_id);
-- 3.3 A draft's per-account totals, replaced while it is a draft (Ruling R6).-- A NULL account is the part of the amount no account on that axis carries.CREATE TABLE workflow_instance_share ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), instance_id UUID NOT NULL, axis_id UUID NOT NULL, analytic_account_id UUID, amount NUMERIC(19,4) NOT NULL CONSTRAINT workflow_instance_share_amount_positive CHECK (amount > 0), CONSTRAINT workflow_instance_share_tenant_id UNIQUE (org_id, id), CONSTRAINT workflow_instance_share_once UNIQUE NULLS NOT DISTINCT (org_id, instance_id, axis_id, analytic_account_id), CONSTRAINT workflow_instance_share_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT workflow_instance_share_axis_fk FOREIGN KEY (org_id, axis_id) REFERENCES analytic_axis (org_id, id), -- MATCH SIMPLE: the unallocated row (NULL account) is not checked. CONSTRAINT workflow_instance_share_account_fk FOREIGN KEY (org_id, axis_id, analytic_account_id) REFERENCES analytic_account (org_id, axis_id, id));
-- 3.4 One approval of one account's share, on one visit to a step. Carries-- the same evidence a decision row does (spec 11A.4).CREATE TABLE workflow_signoff ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), instance_id UUID NOT NULL, definition_id UUID NOT NULL, from_key TEXT NOT NULL, to_key TEXT NOT NULL, -- The decision that entered from_key; NULL only while the instance has -- never had one (a state entered straight from Start). entry_decision_id UUID, grant_id UUID NOT NULL, axis_id UUID NOT NULL, analytic_account_id UUID NOT NULL, share_amount NUMERIC(19,4) NOT NULL CONSTRAINT workflow_signoff_share_positive CHECK (share_amount > 0), currency TEXT NOT NULL CONSTRAINT workflow_signoff_currency_code CHECK (currency ~ '^[A-Z]{3}$'), actor_member_id UUID NOT NULL, authority_member_id UUID NOT NULL, authority_via TEXT NOT NULL CONSTRAINT workflow_signoff_via_known CHECK (authority_via IN ('grant', 'delegation', 'escalation')), delegation_id UUID, eligible_member_ids UUID[] NOT NULL, resolved_for DATE NOT NULL, ip INET, request_id TEXT, decided_at TIMESTAMPTZ NOT NULL, seq BIGSERIAL, CONSTRAINT workflow_signoff_delegation_matches_via CHECK ((authority_via = 'delegation') = (delegation_id IS NOT NULL)), CONSTRAINT workflow_signoff_tenant_id UNIQUE (org_id, id), CONSTRAINT workflow_signoff_once UNIQUE NULLS NOT DISTINCT (org_id, instance_id, entry_decision_id, to_key, analytic_account_id), CONSTRAINT workflow_signoff_instance_fk FOREIGN KEY (org_id, instance_id) REFERENCES workflow_instance (org_id, id), CONSTRAINT workflow_signoff_definition_fk FOREIGN KEY (org_id, definition_id) REFERENCES workflow_definition (org_id, id), CONSTRAINT workflow_signoff_entry_fk FOREIGN KEY (org_id, entry_decision_id) REFERENCES workflow_decision (org_id, id), CONSTRAINT workflow_signoff_grant_fk FOREIGN KEY (org_id, grant_id) REFERENCES workflow_gate_grant (org_id, id), CONSTRAINT workflow_signoff_account_fk FOREIGN KEY (org_id, axis_id, analytic_account_id) REFERENCES analytic_account (org_id, axis_id, id), CONSTRAINT workflow_signoff_actor_fk FOREIGN KEY (org_id, actor_member_id) REFERENCES organization_member (org_id, id), CONSTRAINT workflow_signoff_authority_fk FOREIGN KEY (org_id, authority_member_id) REFERENCES organization_member (org_id, id));CREATE INDEX workflow_signoff_instance_idx ON workflow_signoff (org_id, instance_id, seq);
ALTER TABLE analytic_account_approver ENABLE ROW LEVEL SECURITY;ALTER TABLE analytic_account_approver FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON analytic_account_approver USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE workflow_instance_share ENABLE ROW LEVEL SECURITY;ALTER TABLE workflow_instance_share FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON workflow_instance_share USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);ALTER TABLE workflow_signoff ENABLE ROW LEVEL SECURITY;ALTER TABLE workflow_signoff FORCE ROW LEVEL SECURITY;CREATE POLICY org_isolation ON workflow_signoff USING (org_id = current_setting('app.current_org', true)::uuid) WITH CHECK (org_id = current_setting('app.current_org', true)::uuid);
-- Closed and voided, never deleted: the evidence of who could approve what.REVOKE DELETE ON analytic_account_approver FROM erp_app;GRANT SELECT, INSERT, UPDATE ON analytic_account_approver TO erp_app;-- Replaced while the instance is a draft (Ruling R6).GRANT SELECT, INSERT, DELETE ON workflow_instance_share TO erp_app;REVOKE UPDATE ON workflow_instance_share FROM erp_app;-- Append-only. A correction is a further decision, never an edit.REVOKE UPDATE, DELETE ON workflow_signoff FROM erp_app;GRANT SELECT, INSERT ON workflow_signoff TO erp_app;GRANT USAGE ON SEQUENCE workflow_signoff_seq_seq TO erp_app;
-- +goose Down-- Refuses (check_violation on the re-added kind list) while any published-- grant is account_approver: the older schema cannot represent it, and-- deleting a published grant is not a rollback.DROP TABLE workflow_signoff;DROP TABLE workflow_instance_share;DROP TABLE analytic_account_approver;ALTER TABLE workflow_gate_grant DROP CONSTRAINT workflow_gate_grant_axis_fk;ALTER TABLE workflow_gate_grant DROP CONSTRAINT workflow_gate_grant_account_approver_shape;ALTER TABLE workflow_gate_grant DROP CONSTRAINT workflow_gate_grant_axis_matches_kind;ALTER TABLE workflow_gate_grant DROP CONSTRAINT workflow_gate_grant_grantee_kind_known;ALTER TABLE workflow_gate_grant ADD CONSTRAINT workflow_gate_grant_grantee_kind_check CHECK (grantee_kind IN ('member', 'superior', 'position', 'unit_head', 'levels_up'));ALTER TABLE workflow_gate_grant DROP COLUMN grantee_axis_id;- Step 5: Register the range and the tables.
In internal/kernel/db/migrationranges_test.go add after {170, 189, "Plan 10"},:
{190, 209, "Plan 11"},In internal/kernel/db/retention_test.go:
appendOnly, next to"workflow_decision":// One approval of one account's share (Plan 11). The evidence behind a// step that several people decided; never edited, never erased."workflow_signoff",amendable, after"member_position":// Who approves what is charged to an analytic account (Plan 11), dated// like member_position: closed (valid_to) or voided (withdrawn_at),// never deleted — it is what explains a past sign-off's approver."analytic_account_approver",operational:// A draft's per-account totals (Plan 11, Ruling R6), replaced on every// save while the claim is a draft. What was approved is frozen on each// workflow_signoff row, not here."workflow_instance_share",
In sqlc.yaml, copy the override entry that maps workflow_decision.amount to money.Amount twice, for workflow_instance_share.amount and workflow_signoff.share_amount. Then run make generate.
- Step 6: Run the migration test and the drift tests.
Run: go test ./internal/platform/workflow/ -run TestMigration00190 -count=1 && go test ./internal/kernel/db/... ./internal/gen/... -count=1
Expected: PASS. If TestEveryConstraintIsNamed fails, a constraint above lost its explicit name — fix the name, do not grandfather it.
-
Step 7: Watch the retention guard fail once. Temporarily remove
"workflow_signoff"fromappendOnly; rungo test ./internal/kernel/db/ -run TestOnlyOperationalTablesGrantDeleteToTheAppRole -count=1; expect FAIL namingworkflow_signoffas unlisted; restore. -
Step 8: Commit.
git add db/migrations/00190_project_approval.sql internal/platform/workflow/migration_test.go \ internal/kernel/db/migrationranges_test.go internal/kernel/db/retention_test.go sqlc.yaml internal/gen/sqlcgit commit -m "$(cat <<'EOF'Schema for approval by project: account_approver grants, approvers, shares, sign-offs
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>EOF)"Task 2: Analytic account approvers
Section titled “Task 2: Analytic account approvers”Files:
- Create:
internal/platform/accounting/analytic/approver.go - Create:
internal/platform/accounting/analytic/approver_test.go - Modify:
internal/platform/accounting/analytic/errors.go(one sentinel)
Interfaces:
- Consumes: T1’s
analytic_account_approver; existinggetAccount,analytic.ErrNotFound,analytic.ErrAccountInactive,audit.Write. - Produces:
package analytic
const ( ApproverPosition = "position" ApproverMember = "member")
type Approver struct { ID uuid.UUID `json:"id"` AccountID uuid.UUID `json:"analytic_account_id"` Kind string `json:"grantee_kind"` PositionID *uuid.UUID `json:"position_id"` MemberID *uuid.UUID `json:"member_id"` ValidFrom time.Time `json:"valid_from"` ValidTo *time.Time `json:"valid_to"` WithdrawnAt *time.Time `json:"withdrawn_at"` CreatedBy uuid.UUID `json:"created_by_member_id"` CreatedAt time.Time `json:"created_at"`}
type AssignInput struct { AccountID uuid.UUID Kind string PositionID *uuid.UUID MemberID *uuid.UUID Actor uuid.UUID}
var ErrApproverShape = errors.New("analytic: an approver names exactly one position or one member")var ErrApproverUnknown = errors.New("analytic: the approver is not an active position or member of this organization")
// AssignApprover makes in the account's approver from today.func AssignApprover(ctx context.Context, tx pgx.Tx, c clock.Clock, in AssignInput) (Approver, error)// ApproverHistory is every row for the account, newest first, withdrawn included.func ApproverHistory(ctx context.Context, tx pgx.Tx, accountID uuid.UUID) ([]Approver, error)// LiveApprovers is every account's approver in force on day on.func LiveApprovers(ctx context.Context, tx pgx.Tx, on time.Time) ([]Approver, error)// ApproverChain is the account's live approver, then each ancestor's that has// one, nearest first. Empty when neither the account nor any ancestor has one.func ApproverChain(ctx context.Context, tx pgx.Tx, accountID uuid.UUID, on time.Time) ([]Approver, error)- Step 1: Write the failing tests —
approver_test.go(packageanalytic_test, usingaccountingtest.New(t),e.SeedAxes(t, false),e.SeedMember(t, name),e.H.Adminto seed a position). Tests:
// seat inserts an org_position through the admin pool (orgstruct is not this// package's concern) and returns its id.func seat(t *testing.T, e *accountingtest.Env, code string) uuid.UUID { t.Helper() var unit, id uuid.UUID ctx := context.Background() require.NoError(t, e.H.Admin.QueryRow(ctx, ` INSERT INTO org_unit (org_id, code, name, path, depth) VALUES ($1, $2, $2, $2, 0) RETURNING id`, e.Org, "U"+code).Scan(&unit)) require.NoError(t, e.H.Admin.QueryRow(ctx, ` INSERT INTO org_position (org_id, org_unit_id, code, title, path, depth) VALUES ($1, $2, $3, $3, $3, 0) RETURNING id`, e.Org, unit, code).Scan(&id)) return id}
func TestAssignApproverThenChangeKeepsHistory(t *testing.T)// PRESENCE: assign position P to ProjA → history has 1 live row (valid_to nil).// Advance the clock one day (e.Clock = clock.Fixed(accountingtest.T0.AddDate(0,0,1))),// assign member M → history newest first: [M live, P closed with valid_to = day 2].// LiveApprovers(day 2) contains exactly M for ProjA; LiveApprovers(T0 day) contains P.
func TestAssignTwiceTheSameDayVoidsTheFirst(t *testing.T)// Assign P then M on T0: first row has WithdrawnAt set and ValidTo nil;// LiveApprovers(T0) has only M (presence of P in history, absence from live).
func TestAssignRefusesAMalformedApprover(t *testing.T)// Kind position with MemberID set, kind member with nil MemberID, kind "role":// each ErrApproverShape. An inactive account: ErrAccountInactive. Unknown account: ErrNotFound.// A deactivated member, a retired position, or another org's position: ErrApproverUnknown.// PRESENCE first: the same input with the right shape succeeds.
func TestApproverChainWalksToTheNearestAncestorWithOne(t *testing.T)// Create child ProjA1 under ProjA (CreateAccount with ParentID). Presence: ProjA1// has none of its own → chain == [ProjA's approver]. Then assign ProjA1 its own →// chain == [ProjA1's, ProjA's]. ProjB with none and no parent → empty chain.
func TestAssignWritesAnAuditRow(t *testing.T)// After AssignApprover, audit_log has a row entity_kind 'analytic_account',// action 'approver', entity_id ProjA, with after.approver.id = the new id.e.Org is the organization id field on accountingtest.Env — use whatever field name that struct actually exposes for the org (read accountingtest/env.go; seedOrg inserts it).
-
Step 2: Run and watch fail.
go test ./internal/platform/accounting/analytic/ -run 'Approver' -count=1→ FAIL: undefinedanalytic.AssignApprover. -
Step 3: Implement —
approver.go:
package analytic
import ( "context" "errors" "fmt" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/tenancy")
const approverColumns = `id, analytic_account_id, grantee_kind, position_id, member_id, valid_from, valid_to, withdrawn_at, created_by_member_id, created_at`
func scanApprover(row pgx.Row) (Approver, error) { var a Approver err := row.Scan(&a.ID, &a.AccountID, &a.Kind, &a.PositionID, &a.MemberID, &a.ValidFrom, &a.ValidTo, &a.WithdrawnAt, &a.CreatedBy, &a.CreatedAt) return a, err}
func AssignApprover(ctx context.Context, tx pgx.Tx, c clock.Clock, in AssignInput) (Approver, error) { org, ok := tenancy.OrgFromContext(ctx) if !ok { return Approver{}, fmt.Errorf("analytic: %w", tenancy.ErrNoOrg) } switch { case in.Kind == ApproverPosition && in.PositionID != nil && in.MemberID == nil: case in.Kind == ApproverMember && in.MemberID != nil && in.PositionID == nil: default: return Approver{}, ErrApproverShape } var exists bool var q string var ref uuid.UUID if in.PositionID != nil { q, ref = `SELECT EXISTS (SELECT 1 FROM org_position WHERE org_id = $1 AND id = $2 AND is_active)`, *in.PositionID } else { q, ref = `SELECT EXISTS (SELECT 1 FROM organization_member WHERE org_id = $1 AND id = $2 AND active)`, *in.MemberID } if err := tx.QueryRow(ctx, q, org, ref).Scan(&exists); err != nil { return Approver{}, fmt.Errorf("analytic: approver: %w", err) } if !exists { return Approver{}, ErrApproverUnknown } // The account row lock serializes assignments to one account: the live // row read below cannot change under us. var active bool err := tx.QueryRow(ctx, `SELECT is_active FROM analytic_account WHERE org_id = $1 AND id = $2 FOR UPDATE`, org, in.AccountID).Scan(&active) if errors.Is(err, pgx.ErrNoRows) { return Approver{}, ErrNotFound } if err != nil { return Approver{}, fmt.Errorf("analytic: lock account: %w", err) } if !active { return Approver{}, ErrAccountInactive } today := clock.TodayUTC(c) live, err := scanApprover(tx.QueryRow(ctx, `SELECT `+approverColumns+` FROM analytic_account_approver WHERE org_id = $1 AND analytic_account_id = $2 AND withdrawn_at IS NULL AND valid_from <= $3 AND (valid_to IS NULL OR valid_to > $3)`, org, in.AccountID, today)) hasLive := err == nil if err != nil && !errors.Is(err, pgx.ErrNoRows) { return Approver{}, fmt.Errorf("analytic: live approver: %w", err) } if hasLive { if live.ValidFrom.Equal(today) { _, err = tx.Exec(ctx, `UPDATE analytic_account_approver SET withdrawn_at = $3 WHERE org_id = $1 AND id = $2`, org, live.ID, c.Now().UTC()) } else { _, err = tx.Exec(ctx, `UPDATE analytic_account_approver SET valid_to = $3 WHERE org_id = $1 AND id = $2`, org, live.ID, today) } if err != nil { return Approver{}, fmt.Errorf("analytic: close approver: %w", err) } } a, err := scanApprover(tx.QueryRow(ctx, ` INSERT INTO analytic_account_approver (org_id, analytic_account_id, grantee_kind, position_id, member_id, valid_from, created_by_member_id, created_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING `+approverColumns, org, in.AccountID, in.Kind, in.PositionID, in.MemberID, today, in.Actor, c.Now().UTC())) if err != nil { return Approver{}, fmt.Errorf("analytic: insert approver: %w", err) } var before any if hasLive { before = map[string]any{"approver": live} } if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: "analytic_account", EntityID: in.AccountID, Action: "approver", ActorID: &in.Actor, Before: before, After: map[string]any{"approver": a}}); err != nil { return Approver{}, err } return a, nil}
func ApproverHistory(ctx context.Context, tx pgx.Tx, accountID uuid.UUID) ([]Approver, error) { org, ok := tenancy.OrgFromContext(ctx) if !ok { return nil, fmt.Errorf("analytic: %w", tenancy.ErrNoOrg) } return queryApprovers(ctx, tx, `SELECT `+approverColumns+` FROM analytic_account_approver WHERE org_id = $1 AND analytic_account_id = $2 ORDER BY created_at DESC, id DESC`, org, accountID)}
func LiveApprovers(ctx context.Context, tx pgx.Tx, on time.Time) ([]Approver, error) { org, ok := tenancy.OrgFromContext(ctx) if !ok { return nil, fmt.Errorf("analytic: %w", tenancy.ErrNoOrg) } return queryApprovers(ctx, tx, `SELECT `+approverColumns+` FROM analytic_account_approver WHERE org_id = $1 AND withdrawn_at IS NULL AND valid_from <= $2 AND (valid_to IS NULL OR valid_to > $2) ORDER BY analytic_account_id`, org, on)}
func ApproverChain(ctx context.Context, tx pgx.Tx, accountID uuid.UUID, on time.Time) ([]Approver, error) { org, ok := tenancy.OrgFromContext(ctx) if !ok { return nil, fmt.Errorf("analytic: %w", tenancy.ErrNoOrg) } // The account and its ancestors, nearest first; each joined to its live // approver where it has one. return queryApprovers(ctx, tx, ` WITH RECURSIVE up(id, parent_id, depth) AS ( SELECT id, parent_id, 0 FROM analytic_account WHERE org_id = $1 AND id = $2 UNION ALL SELECT a.id, a.parent_id, up.depth + 1 FROM analytic_account a JOIN up ON a.id = up.parent_id WHERE a.org_id = $1) SELECT `+prefixed("r", approverColumns)+` FROM up JOIN analytic_account_approver r ON r.org_id = $1 AND r.analytic_account_id = up.id WHERE r.withdrawn_at IS NULL AND r.valid_from <= $3 AND (r.valid_to IS NULL OR r.valid_to > $3) ORDER BY up.depth`, org, accountID, on)}
func queryApprovers(ctx context.Context, tx pgx.Tx, sql string, args ...any) ([]Approver, error) { rows, err := tx.Query(ctx, sql, args...) if err != nil { return nil, fmt.Errorf("analytic: approvers: %w", err) } defer rows.Close() out := []Approver{} for rows.Next() { a, err := scanApprover(rows) if err != nil { return nil, err } out = append(out, a) } return out, rows.Err()}prefixed("r", approverColumns) returns the column list with each column qualified by r.; write it as a small unexported helper in this file (split on ,, trim, prefix, join with , ). ErrApproverShape and ErrApproverUnknown are declared in errors.go beside the other sentinels, not in approver.go.
-
Step 4: Run.
go test ./internal/platform/accounting/analytic/ -count=1→ PASS (new and existing tests). -
Step 5: Commit —
Analytic accounts get dated approvers(with the co-author line).
Task 3: The account_approver grant — model, validation, persistence
Section titled “Task 3: The account_approver grant — model, validation, persistence”Files:
- Modify:
internal/platform/workflow/model.go,validate.go,store.go - Test:
internal/platform/workflow/validate_test.go,store_test.go
Interfaces:
- Consumes: T1’s
workflow_gate_grant.grantee_axis_id. - Produces:
const GranteeAccountApprover GranteeKind = "account_approver"
// GrantSpec gains, after LevelsUp:AxisID *uuid.UUID `json:"grantee_axis_id,omitempty"`
// SignoffKinds are the definition kinds whose module reports shares// (workflow.SetShares). P12: expense claims only in v1.var SignoffKinds = []string{"expense_claim"}
// SignoffGrant is the state's own account_approver grant (Ruling R5: all_states// grants are not consulted). Validation guarantees at most one.func (d Definition) SignoffGrant(state string) (Grant, bool)- Step 1: Write the failing validation tests in
validate_test.go, each built from the package’s existingvalid()graph helper (read it first; it returns a well-formedexpense_claim-shaped graph with apending→approvedtarget edge). Use one helper:
func signoffGraph(mutate func(g *workflow.Graph)) workflow.Graph { g := valid() axis := uuid.New() g.Grants = append(g.Grants, workflow.GrantSpec{StateKey: "approved", GranteeKind: workflow.GranteeAccountApprover, AxisID: &axis}) // P6: the sign-off state holds only this rule — drop its other own grants. g.Grants = slices.DeleteFunc(g.Grants, func(gr workflow.GrantSpec) bool { return gr.StateKey == "approved" && gr.GranteeKind != workflow.GranteeAccountApprover }) if mutate != nil { mutate(&g) } return g}
func codes(vs []workflow.Violation) []string { var out []string for _, v := range vs { out = append(out, v.Code) } return out}Tests (each asserts the clean graph first — PRESENCE of validity — then the mutation’s code):
func TestAnAccountApproverGrantIsValidOnAPendingStepOfAnExpenseClaim(t *testing.T)// require.Empty(ValidateGraph(signoffGraph(nil), "MYR"))
func TestAnAccountApproverGrantNeedsItsAxisAndNoOtherReference(t *testing.T)// AxisID nil → "grantee_ref_invalid"; AxisID set on a superior grant → "grantee_ref_invalid"
func TestAnAccountApproverGrantCarriesNoFloorAndIsNotAllStates(t *testing.T)// MinAmount "100.0000" MYR → "account_approver_floor"; AllStates+StateKey "" → "account_approver_all_states"
func TestAnAccountApproverGrantOnlyOnSignoffKinds(t *testing.T)// g.Kind = "advance" (and the graph otherwise valid) → "account_approver_kind"
func TestAnAccountApproverStepHoldsOnlyThatRule(t *testing.T)// add {StateKey:"approved", GranteeKind: GranteeSuperior} → "account_approver_not_alone"
func TestAnAccountApproverGrantOnlyOnAPendingStepEnteredByTarget(t *testing.T)// move the grant to a state whose semantic is accrual → "account_approver_state";// add a reporter-gated edge into "approved" → "account_approver_entry"In store_test.go, one DB-aware test using the package’s store fixture (read how the existing grantee_member_unknown test builds its world and calls workflow.Validate):
func TestValidateRefusesAnAxisThatIsUnknownRetiredOrNotForExpenses(t *testing.T)// PRESENCE: an active axis applying to expense → no grantee_axis_* code.// Random uuid → "grantee_axis_unknown"; is_active=false → "grantee_axis_inactive";// applies_to {'income'} → "grantee_axis_not_expense". Seed axes through w.Admin:// INSERT INTO analytic_axis (org_id, key, name, applies_to, is_active) VALUES ($1,$2,$2,$3,$4) RETURNING idfunc TestAPublishedAccountApproverGrantLoadsBackWithItsAxis(t *testing.T)// Publish signoffGraph with a real axis id; Load it; the grant's AxisID equals it.-
Step 2: Run and watch fail.
go test ./internal/platform/workflow/ -run 'AccountApprover|Axis' -count=1→ FAIL: undefinedGranteeAccountApprover. -
Step 3: Implement.
model.go: add the constant to the GranteeKind block, AxisID to GrantSpec after LevelsUp, SignoffKinds, and:
func (d Definition) SignoffGrant(state string) (Grant, bool) { for i, g := range d.Grants { if !g.AllStates && g.StateKey == state && g.GranteeKind == GranteeAccountApprover { var id uuid.UUID if i < len(d.GrantIDs) { id = d.GrantIDs[i] } return Grant{ID: id, GrantSpec: g}, true } } return Grant{}, false}validate.go, in wellFormed’s grant loop: widen the reference tuple to four slots and add the new kind:
refs := map[GranteeKind][4]bool{ // member, position, levels_up, axis GranteeMember: {true, false, false, false}, GranteePosition: {false, true, false, false}, GranteeLevelsUp: {false, false, true, false}, GranteeSuperior: {}, GranteeUnitHead: {}, GranteeAccountApprover: {false, false, false, true}, } want, known := refs[gr.GranteeKind] if !known { c.add(0, "grantee_kind_unknown", ref, "grantee kind %q is not known", gr.GranteeKind) } else if (gr.MemberID != nil) != want[0] || (gr.PositionID != nil) != want[1] || (gr.LevelsUp != nil) != want[2] || (gr.AxisID != nil) != want[3] { c.add(0, "grantee_ref_invalid", ref, "a %s grant names exactly the reference its kind needs", gr.GranteeKind) } else if gr.LevelsUp != nil && (*gr.LevelsUp < 1 || *gr.LevelsUp > 9) { c.add(0, "levels_up_invalid", ref, "levels_up must be 1..9") } if gr.GranteeKind == GranteeAccountApprover { c.accountApprover(i, gr) }and a new method in the same file:
// accountApprover is the sign-off grant's shape (spec 4.4, P6, P12, Rulings R4, R5).func (c *checker) accountApprover(i int, gr GrantSpec) { ref := fmt.Sprintf("grants[%d]", i) if gr.AllStates { c.add(0, "account_approver_all_states", ref, "an account approver signs one state, not all states") return } if gr.MinAmount != nil { c.add(0, "account_approver_floor", ref, "an account approver step has no amount floor") } if !slices.Contains(SignoffKinds, c.g.Kind) { c.add(0, "account_approver_kind", ref, "%s does not report what it is charged to", c.g.Kind) } if s, ok := c.g.State(gr.StateKey); ok && s.Semantic != posting.Pending { c.add(0, "account_approver_state", ref, "an account approver signs a pending step, not %s", s.Semantic) } for j, other := range c.g.Grants { if j != i && !other.AllStates && other.StateKey == gr.StateKey { c.add(0, "account_approver_not_alone", ref, "state %q holds only its account approver", gr.StateKey) break } } for _, e := range c.g.Edges { if e.To == gr.StateKey && e.Gate != GateTarget { c.add(0, "account_approver_entry", ref, "state %q is entered by approval only", gr.StateKey) break } }}(Import posting and slices if not already imported. Rule 0 is correct: these are structural shape checks, and WorkflowViolation.rule’s maximum of 7 stays.)
store.go:
PublishGraph’s grant INSERT gainsgrantee_axis_idas$13bound togr.AxisID.loadWhere’s grant SELECT gainsgrantee_axis_idafterlevels_up, scanned into&g.AxisID.Validate: collectaxesfromgr.AxisIDlikemembers, read them in one query, and add after the member/position checks:
type axisRow struct{ active, expense bool } axisRows := map[uuid.UUID]axisRow{} if len(axes) > 0 { rows, err := tx.Query(ctx, `SELECT id, is_active, 'expense' = ANY(applies_to) FROM analytic_axis WHERE org_id = $1 AND id = ANY($2)`, org, axes) if err != nil { return nil, fmt.Errorf("workflow: validate axes: %w", err) } for rows.Next() { var id uuid.UUID var r axisRow if err := rows.Scan(&id, &r.active, &r.expense); err != nil { rows.Close() return nil, err } axisRows[id] = r } rows.Close() if err := rows.Err(); err != nil { return nil, err } } // ...inside the existing per-grant loop: if gr.AxisID != nil { r, ok := axisRows[*gr.AxisID] switch { case !ok: vs = append(vs, Violation{Rule: 0, Code: "grantee_axis_unknown", Ref: ref, Detail: "the axis is not an analytic axis of this organization"}) case !r.active: vs = append(vs, Violation{Rule: 0, Code: "grantee_axis_inactive", Ref: ref, Detail: "the axis is retired"}) case !r.expense: vs = append(vs, Violation{Rule: 0, Code: "grantee_axis_not_expense", Ref: ref, Detail: "the axis does not apply to expenses"}) } }-
Step 4: Run.
go test ./internal/platform/workflow/ -count=1→ PASS, every existing test included. -
Step 5: Watch one guard fail once. Comment out the
account_approver_not_aloneloop; rerunTestAnAccountApproverStepHoldsOnlyThatRule; expect FAIL; restore. -
Step 6: Commit —
Workflow: the account_approver grant kind, validated and persisted.
Task 4: Shares on the instance, and what a subject knows about them
Section titled “Task 4: Shares on the instance, and what a subject knows about them”Files:
- Create:
internal/platform/workflow/shares.go,shares_test.go - Modify:
internal/platform/workflow/authorize.go(Subjectfields),instance.go(subjectOf)
Interfaces:
- Consumes: T1’s
workflow_instance_share,workflow_signoff. - Produces:
// Share is one account's part of an instance's amount on one axis. A nil// AccountID is the part no account on that axis carries (spec P10).type Share struct { AxisID uuid.UUID `json:"axis_id"` AccountID *uuid.UUID `json:"analytic_account_id"` Amount money.Amount `json:"amount"`}
var ErrShareInvalid = errors.New("workflow: a share has a positive amount and appears once")
// SetShares replaces the instance's shares. Only while it is a draft (P7,// the rule SetAmount follows): an approval is given on these shares.func SetShares(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, shares []Share) error// Shares is the instance's shares, by axis then account, unallocated last.func Shares(ctx context.Context, tx pgx.Tx, id uuid.UUID) ([]Share, error)
// Signed is one sign-off already given on the current visit.type Signed struct { ID uuid.UUID `json:"id"` ToKey string `json:"state"` AxisID uuid.UUID `json:"axis_id"` AccountID uuid.UUID `json:"analytic_account_id"` Amount money.Amount `json:"amount"` Actor uuid.UUID `json:"actor_member_id"` AuthorityMember uuid.UUID `json:"authority_member_id"` Via Via `json:"via"` DecidedAt time.Time `json:"decided_at"`}
// Subject gains:InstanceID uuid.UUIDShares []ShareEntryDecisionID *uuid.UUID // the decision that entered the current stateSigned []Signed // sign-offs given since EntryDecisionID, leaving the current state- Step 1: Write the failing tests —
shares_test.go. Readinstance_test.gofirst and reuse its helpers for starting an instance of a small graph and moving it out of draft (grep for the test assertingworkflow.ErrNotDraftfromSetAmountand copy its setup).
func TestSetSharesReplacesTheSetWhileDraft(t *testing.T)// Start an instance; SetShares([{axis, &a, "60"}, {axis, &b, "40"}]) → Shares returns both, ordered.// SetShares([{axis, &a, "100"}]) → Shares returns exactly that one (replacement, not append).// audit_log has an entity_kind 'workflow_instance', action 'shares' row whose before holds the first set.
func TestSetSharesIsRefusedOnceTheInstanceLeavesDraft(t *testing.T)// PRESENCE: SetShares succeeds on the draft. Submit. Then SetShares → ErrNotDraft and Shares unchanged.
func TestSetSharesRefusesZeroAndDuplicates(t *testing.T)// Amount "0" → ErrShareInvalid; the same (axis, account) twice → ErrShareInvalid;// two unallocated rows on one axis → ErrShareInvalid. PRESENCE: the valid set succeeds first.Seed the axis and accounts through w.Admin (INSERT INTO analytic_axis …, INSERT INTO analytic_account (org_id, axis_id, code, name) …). Task 5 turns these inserts into fixture helpers; here inline SQL is fine.
-
Step 2: Run and watch fail.
go test ./internal/platform/workflow/ -run 'SetShares' -count=1→ FAIL: undefined. -
Step 3: Implement
shares.go:
package workflow
import ( "context" "errors" "fmt" "slices" "time"
"github.com/google/uuid" "github.com/jackc/pgx/v5"
"github.com/mcom/erp/internal/kernel/audit" "github.com/mcom/erp/internal/kernel/clock" "github.com/mcom/erp/internal/kernel/money" "github.com/mcom/erp/internal/platform/posting")
func SetShares(ctx context.Context, tx pgx.Tx, c clock.Clock, id uuid.UUID, shares []Share) error { org, err := orgOf(ctx) if err != nil { return err } type key struct { axis uuid.UUID account uuid.UUID // uuid.Nil for unallocated } seen := map[key]bool{} for _, s := range shares { k := key{axis: s.AxisID} if s.AccountID != nil { k.account = *s.AccountID } if !s.Amount.IsPositive() || seen[k] { return ErrShareInvalid } seen[k] = true } inst, err := lockInstance(ctx, tx, org, id) if err != nil { return err } def, err := Load(ctx, tx, inst.DefinitionID) if err != nil { return err } if s, _ := def.State(inst.StateKey); s.Semantic != posting.Draft { return ErrNotDraft } before, err := Shares(ctx, tx, id) if err != nil { return err } after := sortShares(slices.Clone(shares)) if sharesEqual(before, after) { return nil // an edit that moves no money between accounts writes nothing } if _, err := tx.Exec(ctx, `DELETE FROM workflow_instance_share WHERE org_id = $1 AND instance_id = $2`, org, id); err != nil { return fmt.Errorf("workflow: clear shares: %w", err) } for _, s := range after { if _, err := tx.Exec(ctx, ` INSERT INTO workflow_instance_share (org_id, instance_id, axis_id, analytic_account_id, amount) VALUES ($1, $2, $3, $4, $5)`, org, id, s.AxisID, s.AccountID, s.Amount.String()); err != nil { return fmt.Errorf("workflow: insert share: %w", err) } } return audit.Write(ctx, tx, c, audit.Entry{EntityKind: "workflow_instance", EntityID: id, Action: "shares", Before: map[string]any{"shares": before}, After: map[string]any{"shares": after}})}
func Shares(ctx context.Context, tx pgx.Tx, id uuid.UUID) ([]Share, error) { org, err := orgOf(ctx) if err != nil { return nil, err } rows, err := tx.Query(ctx, ` SELECT axis_id, analytic_account_id, amount::text FROM workflow_instance_share WHERE org_id = $1 AND instance_id = $2`, org, id) if err != nil { return nil, fmt.Errorf("workflow: shares: %w", err) } defer rows.Close() out := []Share{} for rows.Next() { var s Share var amount string if err := rows.Scan(&s.AxisID, &s.AccountID, &amount); err != nil { return nil, err } if s.Amount, err = money.Parse(amount); err != nil { return nil, err } out = append(out, s) } if err := rows.Err(); err != nil { return nil, err } return sortShares(out), nil}
// sortShares orders by axis, then account, the unallocated row last.func sortShares(s []Share) []Share { slices.SortFunc(s, func(a, b Share) int { if c := slices.Compare(a.AxisID[:], b.AxisID[:]); c != 0 { return c } switch { case a.AccountID == nil && b.AccountID == nil: return 0 case a.AccountID == nil: return 1 case b.AccountID == nil: return -1 } return slices.Compare(a.AccountID[:], b.AccountID[:]) }) return s}
func sharesEqual(a, b []Share) bool { return slices.EqualFunc(a, b, func(x, y Share) bool { return x.AxisID == y.AxisID && x.Amount.Cmp(y.Amount) == 0 && ((x.AccountID == nil && y.AccountID == nil) || (x.AccountID != nil && y.AccountID != nil && *x.AccountID == *y.AccountID)) })}
// signedSince is every sign-off given on this visit to a state — scoped by// the decision that entered it, so a resubmission starts clean (P8).func signedSince(ctx context.Context, tx pgx.Tx, org, instanceID uuid.UUID, fromKey string, entry *uuid.UUID) ([]Signed, error) { rows, err := tx.Query(ctx, ` SELECT id, to_key, axis_id, analytic_account_id, share_amount::text, actor_member_id, authority_member_id, authority_via, decided_at FROM workflow_signoff WHERE org_id = $1 AND instance_id = $2 AND from_key = $3 AND entry_decision_id IS NOT DISTINCT FROM $4 ORDER BY seq`, org, instanceID, fromKey, entry) if err != nil { return nil, fmt.Errorf("workflow: sign-offs: %w", err) } defer rows.Close() out := []Signed{} for rows.Next() { var s Signed var amount, via string if err := rows.Scan(&s.ID, &s.ToKey, &s.AxisID, &s.AccountID, &amount, &s.Actor, &s.AuthorityMember, &via, &s.DecidedAt); err != nil { return nil, err } s.Via = Via(via) if s.Amount, err = money.Parse(amount); err != nil { return nil, err } out = append(out, s) } return out, rows.Err()}(Signed and ErrShareInvalid are declared in this file too; Signed.DecidedAt is what uses the time import.)
subjectOf in instance.go: before the dispute block, fill the new fields:
s.InstanceID = inst.ID var err error if s.Shares, err = Shares(ctx, tx, inst.ID); err != nil { return s, err } var entry uuid.UUID err = tx.QueryRow(ctx, ` SELECT id FROM workflow_decision WHERE org_id = $1 AND instance_id = $2 AND to_key = $3 ORDER BY seq DESC LIMIT 1`, org, inst.ID, inst.StateKey).Scan(&entry) if err != nil && !errors.Is(err, pgx.ErrNoRows) { return s, fmt.Errorf("workflow: entry decision: %w", err) } if err == nil { s.EntryDecisionID = &entry } if s.Signed, err = signedSince(ctx, tx, org, inst.ID, inst.StateKey, s.EntryDecisionID); err != nil { return s, err }(Adjust the existing dispute block’s err declarations so the function compiles with the shared err.)
-
Step 4: Run.
go test ./internal/platform/workflow/ -count=1→ PASS. -
Step 5: Commit —
Workflow: an instance's shares, set while it is a draft.
Task 5: Resolving sign-offs, routing, and the preview
Section titled “Task 5: Resolving sign-offs, routing, and the preview”Files:
- Create:
internal/platform/workflow/signoff.go,signoff_test.go - Modify:
internal/platform/workflow/resolve.go(gap constants,Gapfields),authorize.go(Route,StepRouting),instance.go(the twoRoutecalls),workflowtest/fixture.go(helpers),internal/modules/workflow/drafts.go(routing handler)
Interfaces:
- Consumes: T2
analytic.ApproverChain,analytic.Approver; T3Definition.SignoffGrant; T4Share,Subject.Shares. - Produces:
const ( GapUnallocated = "unallocated" // part of the amount carries no account on the step's axis GapNoAccountApprover = "no_account_approver" // the account, and every ancestor, has no approver)
// Gap gains:AxisID *uuid.UUID `json:"axis_id,omitempty"`AccountID *uuid.UUID `json:"analytic_account_id,omitempty"`
// SignoffRouting is one required sign-off: an account's share and who may sign it.type SignoffRouting struct { AxisID uuid.UUID `json:"axis_id"` AccountID uuid.UUID `json:"analytic_account_id"` AccountCode string `json:"account_code"` AccountName string `json:"account_name"` Amount money.Amount `json:"amount"` Members []uuid.UUID `json:"members"` Escalated bool `json:"escalated"` Gap *Gap `json:"gap,omitempty"`}
// ResolveSignoffs is spec 4.1 for one account_approver grant: one entry per// account with a share on the grant's axis, plus an unallocated gap for any// share of the amount that carries no account there.func ResolveSignoffs(ctx context.Context, tx pgx.Tx, g Grant, shares []Share, reporter uuid.UUID, on time.Time) ([]SignoffRouting, []Gap, error)
// StepRouting gains:Signoffs []SignoffRouting `json:"signoffs,omitempty"`
// Route gains the instance's shares:func Route(ctx context.Context, tx pgx.Tx, def Definition, reporter uuid.UUID, amount money.Amount, shares []Share, on time.Time) ([]StepRouting, error)
// PreviewShares is a share of amount on every active account of every// sign-off step's axis (Ruling R11): the preview's "who approves each project".func PreviewShares(ctx context.Context, tx pgx.Tx, def Definition, amount money.Amount) ([]Share, error)
// workflowtest.World gains:func (w *World) Axis(key string) uuid.UUID // applies to expensefunc (w *World) Account(axis uuid.UUID, code string, parent *uuid.UUID) uuid.UUIDfunc (w *World) ApproverPosition(account, position uuid.UUID) // valid from Jan1func (w *World) ApproverMember(account, member uuid.UUID) // valid from Jan1- Step 1: Fixture helpers in
workflowtest/fixture.go, inserting throughw.Adminexactly asSeat/Sitdo (withw.Organdcreated_by_member_id= a member created once by the fixture for the purpose, e.g.w.Member("Admin")cached on the World):
func (w *World) Axis(key string) uuid.UUID { w.t.Helper() var id uuid.UUID require.NoError(w.t, w.Admin.QueryRow(w.Ctx, ` INSERT INTO analytic_axis (org_id, key, name, applies_to) VALUES ($1, $2, $2, ARRAY['expense']) RETURNING id`, w.Org, key).Scan(&id)) return id}Account inserts analytic_account (org_id, axis_id, code, name, parent_id); ApproverPosition / ApproverMember insert analytic_account_approver with valid_from = Jan1. Match the field names the World actually uses (w.t, w.Org, w.Ctx are illustrative — use the real ones).
- Step 2: Write the failing tests —
signoff_test.go. Chart: reusersNewChart(t)(CEO > HEAD > STAFF). Add membersx,y,wand a seatPMheld byx.
func signoffGrant(axis uuid.UUID) workflow.Grant { return workflow.Grant{ID: uuid.New(), GrantSpec: workflow.GrantSpec{StateKey: "approved", GranteeKind: workflow.GranteeAccountApprover, AxisID: &axis, OnVacancy: workflow.VacancyEscalate, MaxEscalationLevels: 2}}}
func share(axis uuid.UUID, account *uuid.UUID, amount string) workflow.Share { return workflow.Share{AxisID: axis, AccountID: account, Amount: money.MustParse(amount)}}
func TestEachChargedAccountResolvesToItsOwnApprover(t *testing.T)// A → position PM (held by x), B → member y. Shares A 60, B 40, reporter STAFF.// Two routings, ordered as the shares: A {Members [x], Amount 60, AccountCode "PA"}, B {Members [y], Amount 40}.// No gaps.
func TestAnAccountWithNoApproverInheritsItsParents(t *testing.T)// A1 child of A. PRESENCE: A has approver x → A1's routing Members [x], no gap.// ABSENCE of inheritance where there is nothing to inherit: B (no approver, no parent)// → Gap{Reason: no_account_approver, AccountID: B}.
func TestAClaimantNeverSignsTheirOwnShare(t *testing.T)// PRESENCE: reporter STAFF, A → x: Members [x].// Reporter x (give x a primary seat under HEAD): A's own approver is x → skipped.// With parent P approved by w: Members [w], Escalated true.// Without a parent approver: Members = x's superior [head], Escalated true.// x sat at CEO (no superior): Gap{Reason: only_the_claimant}.
func TestUnallocatedAmountIsAGapNamingTheAxis(t *testing.T)// shares {axis, nil, "25"}, {axis, &A, "75"} → gaps == [{Reason: unallocated, AxisID: axis}], one routing (A).
func TestRouteMarksASignoffStepUnroutableUntilEveryShareHasAnApprover(t *testing.T)// Definition with a sign-off grant on "approved" (build like azNew does). Shares on A and B, B unapproved:// Route → the "approved" step: Routable false, Gaps contains no_account_approver for B, Signoffs len 2.// Assign B an approver → Routable true (PRESENCE of the routable path after the absence).// The other steps of the same definition route exactly as before (compare with Route on a// copy of the definition without the sign-off grant but the original superior grant: same Eligible).
func TestPreviewSharesCoversEveryActiveAccountOnTheAxis(t *testing.T)// Axis with A, B active and C retired (UPDATE analytic_account SET is_active=false) → shares for A and B only, each at the amount.-
Step 3: Run and watch fail.
go test ./internal/platform/workflow/ -run 'Signoff|Share|Unallocated|Inherits|Claimant' -count=1→ FAIL: undefinedResolveSignoffs. -
Step 4: Implement
signoff.go(resolution part):
package workflow
// Resolution of account_approver grants (spec 4.1). One grant yields one// required sign-off per account the instance's shares charge on its axis.
func ResolveSignoffs(ctx context.Context, tx pgx.Tx, g Grant, shares []Share, reporter uuid.UUID, on time.Time) ([]SignoffRouting, []Gap, error) { org, err := orgOf(ctx) if err != nil { return nil, nil, err } var out []SignoffRouting var gaps []Gap for _, sh := range shares { if g.AxisID == nil || sh.AxisID != *g.AxisID { continue } if sh.AccountID == nil { axis := sh.AxisID gaps = append(gaps, Gap{GrantID: g.ID, Reason: GapUnallocated, AxisID: &axis}) continue } r := SignoffRouting{AxisID: sh.AxisID, AccountID: *sh.AccountID, Amount: sh.Amount, Members: []uuid.UUID{}} if err := tx.QueryRow(ctx, `SELECT code, name FROM analytic_account WHERE org_id = $1 AND id = $2`, org, r.AccountID).Scan(&r.AccountCode, &r.AccountName); err != nil { return nil, nil, fmt.Errorf("workflow: sign-off account: %w", err) } if err := resolveAccount(ctx, tx, org, g, &r, reporter, on); err != nil { return nil, nil, err } if r.Gap != nil { r.Gap.GrantID = g.ID account := r.AccountID r.Gap.AccountID = &account gaps = append(gaps, *r.Gap) } out = append(out, r) } return out, gaps, nil}
// resolveAccount walks the account's approver chain (P5), skipping any link// that resolves only to the claimant (P9), then falls back to the claimant's// superior. A vacancy follows the grant's on_vacancy, as a position grant's does.func resolveAccount(ctx context.Context, tx pgx.Tx, org uuid.UUID, g Grant, r *SignoffRouting, reporter uuid.UUID, on time.Time) error { chain, err := analytic.ApproverChain(ctx, tx, r.AccountID, on) if err != nil { return err } if len(chain) == 0 { r.Gap = &Gap{Reason: GapNoAccountApprover} return nil } skipped := false for _, ap := range chain { var members []uuid.UUID var gap *Gap switch ap.Kind { case analytic.ApproverPosition: var escalated bool members, escalated, gap, err = seat(ctx, tx, g, *ap.PositionID, on) if err != nil { return err } r.Escalated = r.Escalated || escalated case analytic.ApproverMember: members = []uuid.UUID{*ap.MemberID} } if gap != nil { r.Gap = gap return nil } if members, err = activeOnly(ctx, tx, org, members); err != nil { return err } if len(members) == 0 { r.Gap = &Gap{Reason: GapMemberInactive, MemberID: ap.MemberID} return nil } others := slices.DeleteFunc(slices.Clone(members), func(m uuid.UUID) bool { return m == reporter }) if len(others) > 0 { slices.SortFunc(others, func(a, b uuid.UUID) int { return slices.Compare(a[:], b[:]) }) r.Members, r.Escalated = others, r.Escalated || skipped return nil } skipped = true // only the claimant: P9, the next link up } sup, err := orgstruct.SuperiorOf(ctx, tx, reporter, on) var nse *orgstruct.NoSuperiorError if err != nil && !errors.As(err, &nse) { return err } if sup, err = activeOnly(ctx, tx, org, sup); err != nil { return err } sup = slices.DeleteFunc(sup, func(m uuid.UUID) bool { return m == reporter }) if len(sup) == 0 { r.Gap = &Gap{Reason: GapOnlyTheClaimant, MemberID: &reporter} return nil } r.Members, r.Escalated = sup, true return nil}
func PreviewShares(ctx context.Context, tx pgx.Tx, def Definition, amount money.Amount) ([]Share, error) { org, err := orgOf(ctx) if err != nil { return nil, err } var out []Share seen := map[uuid.UUID]bool{} for _, state := range def.ApprovalStates() { g, ok := def.SignoffGrant(state) if !ok || seen[*g.AxisID] { continue } seen[*g.AxisID] = true rows, err := tx.Query(ctx, `SELECT id FROM analytic_account WHERE org_id = $1 AND axis_id = $2 AND is_active ORDER BY code`, org, *g.AxisID) if err != nil { return nil, fmt.Errorf("workflow: preview shares: %w", err) } for rows.Next() { var id uuid.UUID if err := rows.Scan(&id); err != nil { rows.Close() return nil, err } out = append(out, Share{AxisID: *g.AxisID, AccountID: &id, Amount: amount}) } rows.Close() if err := rows.Err(); err != nil { return nil, err } } return out, nil}Imports: internal/platform/accounting/analytic, internal/platform/orgstruct, internal/kernel/money, errors, fmt, slices, time, uuid, pgx. The dependency is one-way: analytic imports nothing from workflow (verified with go list -deps).
resolve.go: add the two gap constants to the Gap* block and the two fields to Gap.
authorize.go: add Signoffs to StepRouting; change Route’s signature and open its loop with:
for _, state := range def.ApprovalStates() { if g, ok := def.SignoffGrant(state); ok { routes, gaps, err := ResolveSignoffs(ctx, tx, g, shares, reporter, on) if err != nil { return nil, err } st := StepRouting{State: state, Signoffs: routes, Gaps: gaps, Eligible: []uuid.UUID{}} for _, r := range routes { st.Eligible = append(st.Eligible, r.Members...) } slices.SortFunc(st.Eligible, func(a, b uuid.UUID) int { return slices.Compare(a[:], b[:]) }) st.Eligible = slices.Compact(st.Eligible) st.Resolution = []Resolved{{Grant: g, GrantID: g.ID, Kind: g.GranteeKind, Floor: g.Floor().String(), Members: st.Eligible}} st.Routable = len(gaps) == 0 out = append(out, st) continue } // ...the existing body, unchangedinstance.go: both Route(...) calls pass the subject’s shares — in Transition, Route(ctx, tx, def, inst.Reporter, inst.Amount, subj.Shares, today); in AvailableTransitions, the same with its own subj.
internal/modules/workflow/drafts.go routing handler: inside its InTx closure, after Latest:
shares, err := engine.PreviewShares(ctx, tx, def, amount) if err != nil { return err } steps, err = engine.Route(ctx, tx, def, member, amount, shares, clock.TodayUTC(h.deps.Clock)) return err-
Step 5: Run.
go test ./internal/platform/workflow/... ./internal/modules/workflow/... -count=1→ PASS, existing tests included (they pass nil shares through definitions with no sign-off grant, which take the unchanged path). -
Step 6: Watch P9 fail once. In
resolveAccount, delete theslices.DeleteFunc(... == reporter)filter;TestAClaimantNeverSignsTheirOwnSharemust go red with the claimant among Members; restore. -
Step 7: Commit —
Workflow: resolve one sign-off per charged account; route and preview them.
Task 6: Signing — authorization, recording, the last sign-off moves the step
Section titled “Task 6: Signing — authorization, recording, the last sign-off moves the step”Files:
- Modify:
internal/platform/workflow/authorize.go(extractauthorizeInBand; sign-off branch;Authorityfields;RefuseAlreadySigned) - Modify:
internal/platform/workflow/instance.go(Transition,AvailableTransitions,Decision,Available) - Modify:
internal/platform/workflow/resolve.go(Resolved.Signoffs) - Modify:
internal/platform/workflow/signoff.go(recording,Signoffsstatus) - Test:
internal/platform/workflow/signoff_test.go
Interfaces:
- Consumes: T4
Subject.{Shares,EntryDecisionID,Signed},Signed; T5ResolveSignoffs,SignoffRouting,Definition.SignoffGrant. - Produces:
const RefuseAlreadySigned = "already_signed"
// SignAuthority is one share the actor may sign now, and on whose authority.type SignAuthority struct { Signoff SignoffRouting AuthorityMember uuid.UUID Via Via GrantID uuid.UUID DelegationID *uuid.UUID}
// Authority gains:Signs []SignAuthority // non-nil exactly on a sign-off edge the actor may takeCompletes bool // signing Signs leaves nothing outstanding on the step
// Resolved gains:Signoffs []uuid.UUID `json:"signoffs,omitempty"` // on a sign-off step's decision: every sign-off it rests on
// Decision gains:Pending bool // a sign-off was recorded and the instance did not moveSignoffs []uuid.UUID // the sign-offs this call recorded
// SignShare is one share an Available sign-off edge lets the actor sign.type SignShare struct { AxisID uuid.UUID `json:"axis_id"` AccountID uuid.UUID `json:"analytic_account_id"` AccountCode string `json:"account_code"` AccountName string `json:"account_name"` Amount money.Amount `json:"amount"`}// Available gains:Signs []SignShare `json:"signs,omitempty"`
// SignoffStatus is one required sign-off on the instance's current visit.type SignoffStatus struct { State string `json:"state"` // the step being signed AxisID uuid.UUID `json:"axis_id"` AccountID uuid.UUID `json:"analytic_account_id"` AccountCode string `json:"account_code"` AccountName string `json:"account_name"` Amount money.Amount `json:"amount"` Currency string `json:"currency"` Eligible []uuid.UUID `json:"eligible"` Gap *Gap `json:"gap,omitempty"` Signed *Signed `json:"signed,omitempty"`}// Signoffs is every sign-off required to leave the current state, signed or not.func Signoffs(ctx context.Context, tx pgx.Tx, c clock.Clock, instanceID uuid.UUID) ([]SignoffStatus, error)- Step 1: Write the failing tests (append to
signoff_test.go). Fixture: a definition built likeazNewwhoseapprovedstate has only{StateKey: "approved", GranteeKind: GranteeAccountApprover, AxisID: &axis},pending → approvedtarget-gated,pending → rejectedadvance-gated with reason; an instance started for reporter STAFF, shares A 60 (approver x), B 40 (approver y), submitted topending. Build the definition by publishing it (workflow.PublishGraph) soTransitioncan load it, and start the instance withworkflow.Start— followinstance_test.go’s end-to-end tests for how.
func TestEachApproverSignsOnlyTheirOwnShare(t *testing.T)// PRESENCE: AvailableTransitions for x → the approve edge Allowed, Signs == [A 60].// x transitions pending→approved → Decision.Pending true, one sign-off (A); instance still "pending".// x again → RefusedError{Code: already_signed}. Outsider → RefusedError{Code: not_in_band}.// y (PRESENCE for B) → Decision.Pending false; instance "approved".
func TestTheStepMovesOnTheLastSignoffAndNotBefore(t *testing.T)// After x: count(workflow_decision WHERE to_key='approved') == 0, count(workflow_signoff) == 1.// After y: that count == 1; the decision's resolution JSON has signoffs == both sign-off ids;// count(workflow_signoff) == 2.
func TestOneApproverOfTwoAccountsSignsBothAtOnce(t *testing.T)// B's approver also x → one call: Pending false, two sign-off rows, instance "approved".
func TestAnyRequiredApproverMayReject(t *testing.T)// PRESENCE: outsider pending→rejected refused (not_in_band). x rejects with a reason → "rejected".
func TestAResubmissionNeedsEverySignoffAgain(t *testing.T)// x signs A; y rejects; reporter revises (rejected→draft) and resubmits (draft→pending).// PRESENCE of the old sign-off: count(workflow_signoff) == 1. Signoffs() shows A and B both unsigned.// x's AvailableTransitions Signs == [A] again.
func TestADelegateSignsOnTheDelegatorsShare(t *testing.T)// x delegates expense_claim to d (create through the package's delegation API, as delegation_test.go does).// PRESENCE: before the delegation d is refused not_in_band. After: d signs A → the sign-off row has// actor d, authority x, via delegation, delegation_id set.
func TestEscalationAfterTheSLAReachesAboveTheApprover(t *testing.T)// pending has escalate_after_days 2; x sits under HEAD. Before day 2 HEAD is refused; at day 2 HEAD// signs A on x's authority, via escalation.
func TestSignoffsReportsEachRequiredShare(t *testing.T)// Before any sign-off: two statuses, Signed nil, Eligible [x] / [y]. After x: A's Signed.Actor == x.-
Step 2: Run and watch fail.
go test ./internal/platform/workflow/ -run 'Signs|Signoff|Resubmission|Delegate|Escalation|Reject' -count=1→ FAIL. -
Step 3: Extract
authorizeInBandfromAuthorize— steps 1–3 (own grant, delegation, escalation) move verbatim into:
// inBand is who authorizes a move from within one band of resolved grants.type inBand struct { member uuid.UUID via Via grantID uuid.UUID delegationID *uuid.UUID}
// authorizeInBand is Authorize's steps 1-3 over one band: the actor's own// grant, a delegation from a band member, escalation past the SLA. ok false// with a nil refusal means "not in this band".func authorizeInBand(ctx context.Context, tx pgx.Tx, e EdgeSpec, from StateSpec, s Subject, actor uuid.UUID, on time.Time, band []Resolved, posts bool) (inBand, bool, *Refusal, error)Its body is the current code from // 1. The actor's own grant through the end of the escalation block, with each a.AuthorityMember, a.Via, a.GrantID[, a.DelegationID] = …; return a, nil, nil replaced by return inBand{member: …, via: …, grantID: …, delegationID: …}, true, nil, nil, the self-approval and raiser refusals returned as inBand{}, false, &Refusal{…}, nil, and a final return inBand{}, false, nil, nil. Authorize then calls it and maps a hit back onto a (a.AuthorityMember, a.Via = hit.member, hit.via; gid := hit.grantID; a.GrantID = &gid; a.DelegationID = hit.delegationID), returning RefuseNotInBand as before when not ok. Run the whole package now — go test ./internal/platform/workflow/ -count=1 — every existing authorize test must still pass before going on: the refactor changes no behaviour.
- Step 4: The sign-off branch in
Authorize, right aftera.GateState = gs:
if sg, ok := def.SignoffGrant(gs); ok { return authorizeSignoff(ctx, tx, def, e, from, gs, sg, s, actor, on, a) }and in signoff.go:
func authorizeSignoff(ctx context.Context, tx pgx.Tx, def Definition, e EdgeSpec, from StateSpec, gs string, sg Grant, s Subject, actor uuid.UUID, on time.Time, a Authority) (Authority, *Refusal, error) { routes, gaps, err := ResolveSignoffs(ctx, tx, sg, s.Shares, s.Reporter, on) if err != nil { return a, nil, err } for _, r := range routes { a.Eligible = append(a.Eligible, r.Members...) } slices.SortFunc(a.Eligible, func(x, y uuid.UUID) int { return slices.Compare(x[:], y[:]) }) a.Eligible = slices.Compact(a.Eligible) a.Resolution = []Resolved{{Grant: sg, GrantID: sg.ID, Kind: sg.GranteeKind, Floor: sg.Floor().String(), Members: a.Eligible}}
// Leaving the step any other way (a rejection, gate advance): any one // required approver decides it alone (P8). if e.To != gs { band := []Resolved{{Grant: sg, GrantID: sg.ID, Members: a.Eligible}} hit, ok, ref, err := authorizeInBand(ctx, tx, e, from, s, actor, on, band, false) if err != nil || ref != nil { return a, ref, err } if !ok { return a, &Refusal{Code: RefuseNotInBand, Gaps: gaps, Detail: "you are not among the approvers of what this is charged to"}, nil } gid := hit.grantID a.AuthorityMember, a.Via, a.GrantID, a.DelegationID = hit.member, hit.via, &gid, hit.delegationID return a, nil, nil }
signed := map[uuid.UUID]bool{} actorSigned := false for _, sd := range s.Signed { if sd.ToKey == gs { signed[sd.AccountID] = true actorSigned = actorSigned || sd.Actor == actor } } outstanding := 0 a.Signs = []SignAuthority{} for _, r := range routes { if signed[r.AccountID] { continue } outstanding++ if r.Gap != nil { continue } band := []Resolved{{Grant: sg, GrantID: sg.ID, Members: r.Members}} hit, ok, _, err := authorizeInBand(ctx, tx, e, from, s, actor, on, band, false) if err != nil { return a, nil, err } if ok { a.Signs = append(a.Signs, SignAuthority{Signoff: r, AuthorityMember: hit.member, Via: hit.via, GrantID: hit.grantID, DelegationID: hit.delegationID}) } } if len(a.Signs) == 0 { a.Signs = nil if actorSigned { return a, &Refusal{Code: RefuseAlreadySigned, Detail: "you have signed off everything you can on this step"}, nil } return a, &Refusal{Code: RefuseNotInBand, Gaps: gaps, Detail: "you are not among the approvers of what this is charged to"}, nil } a.Completes = len(a.Signs) == outstanding first := a.Signs[0] gid := first.GrantID a.AuthorityMember, a.Via, a.GrantID, a.DelegationID = first.AuthorityMember, first.Via, &gid, first.DelegationID return a, nil, nil}(authorizeInBand’s self-approval refusal cannot fire here: every band has the reporter removed by resolveAccount. Its posts argument is false: R4 keeps sign-off steps off posting states.)
- Step 5: Recording, in
Transition. Movenow := c.Now()and theip, reqIDblock (audit.OriginFromContext) up to just after the submission check, then insert before theaccrual_opencomment:
if auth.Signs != nil { ids, err := recordSignoffs(ctx, tx, org, inst, def, e, subj, auth, ip, reqID, today, now) if err != nil { return Decision{}, err } if !auth.Completes { if err := audit.Write(ctx, tx, c, audit.Entry{EntityKind: "workflow_instance", EntityID: inst.ID, Action: "signoff", ActorID: &in.Actor, After: map[string]any{"state": inst.StateKey, "to": e.To, "signoff_ids": ids}}); err != nil { return Decision{}, err } return Decision{InstanceID: inst.ID, DefinitionID: def.ID, From: e.From, To: e.To, Gate: e.Gate, ActionKey: e.ActionKey, Actor: in.Actor, AuthorityMember: auth.AuthorityMember, Via: auth.Via, Amount: inst.Amount, Currency: inst.Currency, ResolvedFor: today, Eligible: auth.Eligible, IP: ip, RequestID: reqID, DecidedAt: now, Pending: true, Signoffs: ids}, nil } all := []uuid.UUID{} for _, sd := range subj.Signed { if sd.ToKey == e.To { all = append(all, sd.ID) } } auth.Resolution[0].Signoffs = append(all, ids...) }and in signoff.go:
func recordSignoffs(ctx context.Context, tx pgx.Tx, org uuid.UUID, inst Instance, def Definition, e EdgeSpec, s Subject, auth Authority, ip, reqID *string, today time.Time, now time.Time) ([]uuid.UUID, error) { ids := make([]uuid.UUID, 0, len(auth.Signs)) for _, sa := range auth.Signs { var id uuid.UUID if err := tx.QueryRow(ctx, ` INSERT INTO workflow_signoff (org_id, instance_id, definition_id, from_key, to_key, entry_decision_id, grant_id, axis_id, analytic_account_id, share_amount, currency, actor_member_id, authority_member_id, authority_via, delegation_id, eligible_member_ids, resolved_for, ip, request_id, decided_at) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18::inet, $19, $20) RETURNING id`, org, inst.ID, def.ID, e.From, e.To, s.EntryDecisionID, sa.GrantID, sa.Signoff.AxisID, sa.Signoff.AccountID, sa.Signoff.Amount.String(), inst.Currency, auth.Actor, sa.AuthorityMember, string(sa.Via), sa.DelegationID, sa.Signoff.Members, today, ip, reqID, now).Scan(&id); err != nil { return nil, fmt.Errorf("workflow: record sign-off: %w", err) } ids = append(ids, id) } return ids, nil}The instance row is already locked (lockInstance at the top of Transition), so two approvers signing at once serialize, and the second one’s subjectOf sees the first one’s row: exactly one of them completes the step.
- Step 6:
AvailableTransitions— when building eachAvailable, copy the shares the actor would sign:
var signs []SignShare for _, sa := range auth.Signs { signs = append(signs, SignShare{AxisID: sa.Signoff.AxisID, AccountID: sa.Signoff.AccountID, AccountCode: sa.Signoff.AccountCode, AccountName: sa.Signoff.AccountName, Amount: sa.Signoff.Amount}) } out = append(out, Available{To: e.To, ActionKey: e.ActionKey, Gate: e.Gate, RequiresReason: e.RequiresReason, Allowed: ref == nil, Labels: e.Labels, Refusal: ref, Signs: signs})(auth is the first return of the Authorize call in that loop — rename the existing _ to auth.)
- Step 7:
Signoffsinsignoff.go:
func Signoffs(ctx context.Context, tx pgx.Tx, c clock.Clock, instanceID uuid.UUID) ([]SignoffStatus, error) { org, err := orgOf(ctx) if err != nil { return nil, err } inst, err := Get(ctx, tx, instanceID) if err != nil { return nil, err } def, err := Load(ctx, tx, inst.DefinitionID) if err != nil { return nil, err } subj, err := subjectOf(ctx, tx, org, def, inst) if err != nil { return nil, err } out := []SignoffStatus{} for _, e := range def.EdgesFrom(inst.StateKey) { sg, ok := def.SignoffGrant(e.To) if !ok || e.Gate != GateTarget { continue } routes, _, err := ResolveSignoffs(ctx, tx, sg, subj.Shares, subj.Reporter, clock.TodayUTC(c)) if err != nil { return nil, err } for _, r := range routes { st := SignoffStatus{State: e.To, AxisID: r.AxisID, AccountID: r.AccountID, AccountCode: r.AccountCode, AccountName: r.AccountName, Amount: r.Amount, Currency: inst.Currency, Eligible: r.Members, Gap: r.Gap} for i := range subj.Signed { if subj.Signed[i].ToKey == e.To && subj.Signed[i].AccountID == r.AccountID { st.Signed = &subj.Signed[i] } } out = append(out, st) } } return out, nil}-
Step 8: Run.
go test ./internal/platform/workflow/... -count=1→ PASS. -
Step 9: Watch two guards fail once.
- In
authorizeSignoff, skip thesigned[r.AccountID]check.TestEachApproverSignsOnlyTheirOwnSharemust fail (x can sign A twice). Restore. - In
Transition, forceauth.Completes = true.TestTheStepMovesOnTheLastSignoffAndNotBeforemust fail (the state moves after x). Restore.
- In
-
Step 10: Commit —
Workflow: sign-offs — each account's approver signs; the last one moves the step.
Task 7: Expense claims report their shares, and record a sign-off
Section titled “Task 7: Expense claims report their shares, and record a sign-off”Files:
- Modify:
internal/platform/accounting/analytic/plan.go(addAllocate) - Modify:
internal/platform/expenses/line.go(reprice→SetShares; newclaimShares) - Modify:
internal/platform/expenses/transition.go(a pending sign-off) - Modify:
internal/platform/expenses/claim.go(YourShares) - Test:
internal/platform/accounting/analytic/plan_test.go,internal/platform/expenses/shares_test.go(new),internal/platform/expenses/transition_test.go
Interfaces:
- Consumes: T4
workflow.SetShares,workflow.Share; T6Decision.Pending,Decision.Signoffs,Available.Signs. - Produces:
// analytic: the split Plan computes, without Plan's checks on activeness,// applicability and required axes — for re-deriving the amounts of shares// that were validated when their line was saved.func Allocate(ctx context.Context, tx pgx.Tx, lineBase money.Amount, shares []Share) ([]PlannedShare, error)
// expenses: the outstanding share each listed claim's approve step lets member sign now.func YourShares(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, claims []Claim) (map[uuid.UUID]money.Amount, error)-
Step 1:
Allocatetest first (plan_test.go): withSeedAxes,Allocate(100.0000, [ProjA 33.3333, ProjB 66.6667])returns the sameBaseAmounts asPlan(..., enforceRequired=false)for the same input (PRESENCE of agreement); then retire ProjA (UpdateAccountIsActive false) —Plannow fails withErrAccountInactivewhileAllocatestill returns the same split (the difference is the point of the function). -
Step 2: Implement
Allocate. Readplan.go. Move Plan’s step 5 — the loop that setsBaseAmount = lineBase.Percent(p)for each share of a group and gives the last share of the group the residue — into an unexportedsplitGroup(lineBase money.Amount, group []PlannedShare) errorthat both call;Plan’s behaviour must not change (its tests are the proof).Allocateloads the axis of each account with one query,SELECT id, axis_id FROM analytic_account WHERE org_id = $1 AND id = ANY($2)(ErrNotFoundfor a missing one), groups by axis in input order exactly as Plan does, and callssplitGroupper group. -
Step 3: Write the failing expense tests —
shares_test.goininternal/platform/expenses, using the package’s existing test fixture (readline_test.go: how a claim and a receipt line are created in a draft) plusaccountingtest’s axes if the fixture exposes them (else create the project axis and two accounts withanalytic.CreateAxis/CreateAccountin the test’s transaction):
func TestALineChargedToTwoProjectsReportsBothShares(t *testing.T)// One line, base 100.0000, shares PA 60 / PB 40 → workflow.Shares(instance) ==// [project PA 60.0000, project PB 40.0000, cost_centre unallocated 100.0000] (the cost_centre// axis exists and applies to expense, and the line carries nothing on it). Order per T4's sort.
func TestEditingTheLineReplacesTheShares(t *testing.T)// PRESENCE: the two shares above. Save the line with PA 100 → Shares == [PA 100, cost_centre nil 100].// Withdraw the line → Shares == [].
func TestARetiredAccountDoesNotStopTheClaimBeingEdited(t *testing.T)// Save the line with PA 100; retire PA; edit the line's description only → succeeds, Shares unchanged.- Step 4: Implement
claimSharesand call it fromreprice, right afterworkflow.SetAmount:
shares, err := claimShares(ctx, tx, cl.ID) if err != nil { return Claim{}, err } if err := workflow.SetShares(ctx, tx, c, cl.InstanceID, shares); err != nil { return Claim{}, fmt.Errorf("expenses: set instance shares: %w", err) }// claimShares totals the active lines' shares per axis and account (spec// 3.3). A line with nothing on an axis that applies to expenses adds its// whole base to that axis's unallocated total — which is what refuses the// submission when a sign-off step needs the axis (P10).func claimShares(ctx context.Context, tx pgx.Tx, claimID uuid.UUID) ([]workflow.Share, error) { axes, err := analytic.ListAxes(ctx, tx) if err != nil { return nil, err } var expenseAxes []uuid.UUID for _, a := range axes { if a.IsActive && slices.Contains(a.AppliesTo, chart.TypeExpense) { expenseAxes = append(expenseAxes, a.ID) } } lines, err := ListLines(ctx, tx, claimID) if err != nil { return nil, err } type key struct { axis uuid.UUID account uuid.UUID // uuid.Nil: unallocated } totals := map[key]money.Amount{} var order []key add := func(k key, amt money.Amount) { if _, ok := totals[k]; !ok { order = append(order, k) totals[k] = money.Zero() } totals[k] = totals[k].Add(amt) } for _, l := range lines { if l.WithdrawnAt != nil || !l.BaseAmount.IsPositive() { continue } planned, err := analytic.Allocate(ctx, tx, l.BaseAmount, l.Shares) if err != nil { return nil, fmt.Errorf("expenses: line %d shares: %w", l.LineNo, err) } covered := map[uuid.UUID]bool{} for _, p := range planned { add(key{p.AxisID, p.AnalyticAccountID}, p.BaseAmount) covered[p.AxisID] = true } for _, ax := range expenseAxes { if !covered[ax] { add(key{axis: ax}, l.BaseAmount) } } } out := make([]workflow.Share, 0, len(order)) for _, k := range order { if !totals[k].IsPositive() { continue } s := workflow.Share{AxisID: k.axis, Amount: totals[k]} if k.account != uuid.Nil { id := k.account s.AccountID = &id } out = append(out, s) } return out, nil}(chart.TypeExpense is the constant saveLine already passes to analytic.Plan.)
- Step 5: A pending sign-off in
expenses.Transition. Afterworkflow.Transitionreturnsd, the claim’s own audit and activity rows (step 6) name what happened:
action, verb := "transition", transitionVerb(from, to, known) var decisionID any = d.ID if d.Pending { // Spec 3.4: one approver signed their share; the claim has not moved. action, verb, decisionID = "signoff", "signed_off", nil after["signoff_ids"] = d.Signoffs }— build after with "decision_id": decisionID, pass Action: action to audit.Write and Verb: verb to activity.Record, and for a pending sign-off use the summary updated.Reference + " signed off for " + in.To. Everything else in the function is unchanged: step 5b’s stale-state refusal cannot apply (a sign-off step is pending, Ruling R4, so neither submission, payout nor a reversal).
Test in transition_test.go (publish, through the fixture, an expense_claim version whose reviewed state carries only an account_approver grant on the project axis, and assign PA to one member and PB to another with analytic.AssignApprover):
func TestASignoffOnOneProjectLeavesTheClaimWaitingForTheOther(t *testing.T)// Claim split PA/PB, submitted. PA's approver transitions pending_review→reviewed:// the returned claim is still pending_review; audit_log has action 'signoff' for the claim with// signoff_ids of length 1; activity has verb 'signed_off'. PB's approver: claim reviewed, audit// action 'transition' (PRESENCE of the ordinary path).func TestASubmissionWithoutAProjectIsRefusedAtASignoffWorkflow(t *testing.T)// PRESENCE: with PA 100 the submission succeeds. A second claim with no shares → the submit// transition fails with *workflow.SubmissionRefusedError whose step has an unallocated gap.- Step 6:
YourSharesinclaim.go:
func YourShares(ctx context.Context, tx pgx.Tx, c clock.Clock, member uuid.UUID, claims []Claim) (map[uuid.UUID]money.Amount, error) { out := map[uuid.UUID]money.Amount{} for _, cl := range claims { avail, err := workflow.AvailableTransitions(ctx, tx, c, cl.InstanceID, member) if err != nil { return nil, fmt.Errorf("expenses: your share: %w", err) } for _, a := range avail { if !a.Allowed || len(a.Signs) == 0 { continue } sum := money.Zero() for _, s := range a.Signs { sum = sum.Add(s.Amount) } out[cl.ID] = sum break } } return out, nil}Test: TestYourShareIsWhatTheApproverWouldSignNow — PA’s approver sees 60.0000 for the split claim; after they sign, YourShares has no entry for it (and ToActFiltered no longer lists it for them — PRESENCE before, ABSENCE after).
-
Step 7: Run.
go test ./internal/platform/expenses/... ./internal/platform/accounting/analytic/... -count=1→ PASS. -
Step 8: Commit —
Expense claims report their shares; a sign-off is recorded as one.
Task 8: The API contract, and the approvers endpoints
Section titled “Task 8: The API contract, and the approvers endpoints”Files:
- Modify:
api/openapi.yaml - Regenerate:
internal/gen/api/api.gen.go,web/src/lib/api/schema.d.ts(make generate) - Create:
internal/modules/accounting/approver_handlers.go,approver_handlers_test.go - Modify:
internal/modules/accounting/routes.go,routes_test.go,errors.go(map the two new sentinels)
Interfaces:
-
Consumes: T2’s analytic approver functions.
-
Produces (wire): the schemas below; routes
GET /v1/accounting/analytic-approvers(workflow.definition.read),GET /v1/accounting/analytic-accounts/{id}/approvers(workflow.definition.read),POST /v1/accounting/analytic-accounts/{id}/approvers(workflow.definition.publish). -
Step 1: Declare the contract in
api/openapi.yaml:
WorkflowGranteeKind.enumgainsaccount_approver. (Check the olderGranteeKindschema near line 6412: if nothing$refs it, leave it alone; if something does, add the value there too.)WorkflowGrant.propertiesgainsgrantee_axis_id: { type: string, format: uuid, description: The analytic axis, exactly when grantee_kind is account_approver. }.WorkflowGap.propertiesgainsaxis_idandanalytic_account_id(uuid). Its description lists the reasons, now includingunallocatedandno_account_approver.- New schemas:
WorkflowSignoffRouting: type: object required: [axis_id, analytic_account_id, account_code, account_name, amount, members, escalated] properties: axis_id: { type: string, format: uuid } analytic_account_id: { type: string, format: uuid } account_code: { type: string } account_name: { type: string } amount: { $ref: '#/components/schemas/Money' } members: { type: array, items: { type: string, format: uuid } } escalated: { type: boolean } gap: { $ref: '#/components/schemas/WorkflowGap' } WorkflowSignShare: type: object required: [axis_id, analytic_account_id, account_code, account_name, amount] properties: axis_id: { type: string, format: uuid } analytic_account_id: { type: string, format: uuid } account_code: { type: string } account_name: { type: string } amount: { $ref: '#/components/schemas/Money' } WorkflowSigned: type: object required: [id, state, axis_id, analytic_account_id, amount, actor_member_id, authority_member_id, via, decided_at] properties: id: { type: string, format: uuid } state: { type: string } axis_id: { type: string, format: uuid } analytic_account_id: { type: string, format: uuid } amount: { $ref: '#/components/schemas/Money' } actor_member_id: { type: string, format: uuid } authority_member_id: { type: string, format: uuid } via: { type: string, enum: [grant, delegation, escalation] } decided_at: { type: string, format: date-time } WorkflowSignoffStatus: type: object required: [state, axis_id, analytic_account_id, account_code, account_name, amount, currency, eligible] properties: state: { type: string, description: The step being signed. } axis_id: { type: string, format: uuid } analytic_account_id: { type: string, format: uuid } account_code: { type: string } account_name: { type: string } amount: { $ref: '#/components/schemas/Money' } currency: { type: string } eligible: { type: array, items: { type: string, format: uuid } } gap: { $ref: '#/components/schemas/WorkflowGap' } signed: { $ref: '#/components/schemas/WorkflowSigned' } AnalyticApprover: type: object required: [id, analytic_account_id, grantee_kind, position_id, member_id, valid_from, valid_to, withdrawn_at, created_by_member_id, created_at] properties: id: { type: string, format: uuid } analytic_account_id: { type: string, format: uuid } grantee_kind: { type: string, enum: [position, member] } position_id: { type: string, format: uuid, nullable: true } member_id: { type: string, format: uuid, nullable: true } valid_from: { type: string, format: date } valid_to: { type: string, format: date, nullable: true } withdrawn_at: { type: string, format: date-time, nullable: true } created_by_member_id: { type: string, format: uuid } created_at: { type: string, format: date-time } AssignAnalyticApproverRequest: type: object required: [grantee_kind] additionalProperties: false properties: grantee_kind: { type: string, enum: [position, member] } position_id: { type: string, format: uuid } member_id: { type: string, format: uuid } ExpenseAnalyticOptions: type: object required: [axes] properties: axes: type: array items: type: object required: [id, key, name, is_required, accounts] properties: id: { type: string, format: uuid } key: { type: string } name: { type: string, description: In the request's locale. } is_required: { type: boolean } accounts: type: array items: type: object required: [id, code, name, parent_id, is_active] properties: id: { type: string, format: uuid } code: { type: string } name: { type: string } parent_id: { type: string, format: uuid, nullable: true } is_active: { type: boolean }WorkflowStepRouting.properties.signoffs: { type: array, items: { $ref: '#/components/schemas/WorkflowSignoffRouting' } };WorkflowResolvedGrant.properties.signoffs: { type: array, items: { type: string, format: uuid } };WorkflowAvailableTransition.properties.signs: { type: array, items: { $ref: '#/components/schemas/WorkflowSignShare' } };WorkflowRefusal.code’s description listsalready_signed.ExpenseClaimView:signoffs(array ofWorkflowSignoffStatus) added torequiredandproperties.ExpenseClaim.properties.your_share: { allOf: [{ $ref: '#/components/schemas/Money' }], description: "On scope=to_act only: the shares of this claim the caller may sign now." }(not required).- Paths, each with
401→Unauthenticated,403naming the permission, as/accounting/analytic-axesdoes:/accounting/analytic-approversgetlistLiveAnalyticApprovers→200 {approvers: AnalyticApprover[]}. “Every account’s approver in force today. Requires workflow.definition.read.”/accounting/analytic-accounts/{id}/approversgetlistAnalyticApproverHistory→200 {approvers: AnalyticApprover[]}newest first,404;postassignAnalyticApproverbodyAssignAnalyticApproverRequest→201 AnalyticApprover,404(no such account),409(account retired),422(shape, or not an active position/member). Description: “Requires workflow.definition.publish — who approves is an authorization change, like publishing a grant (P14).”/expenses/analytic-optionsgetlistExpenseAnalyticOptions→200 ExpenseAnalyticOptions. “The active axes that apply to expenses, each with its accounts, for tagging a claim line. Requires expenses.claim.read.”
Run make generate, then go build ./... && pnpm -C web typecheck (typecheck may fail only where new required fields are missing from test fixtures — add signoffs: [] to the ExpenseClaimView fixtures it names; nothing else).
- Step 2: Write the failing handler tests —
approver_handlers_test.go, built onchart_handlers_test.go’snewEnv: add two members to it,publisher(workflow.definition.read+workflow.definition.publish) andchartKeeper(accounting.account.manage+accounting.ledger.readonly), with tokens, the same waymanager/reader/clerkare made.
func TestAssigningAnApproverNeedsThePublishPermission(t *testing.T)// PRESENCE: publisher POSTs {grantee_kind: "member", member_id: <a member>} to an account → 201,// body.valid_to null. ABSENCE (P14): chartKeeper, who can create and rename that account, gets 403.
func TestApproverHistoryAndLiveList(t *testing.T)// After two assignments on different days (advance the env's clock between them) the history has// 2 rows newest first; GET /accounting/analytic-approvers has 1 row for the account.// A caller with neither workflow permission: 403 on both GETs (after the 200s above).
func TestAssignRefusesABadApprover(t *testing.T)// {grantee_kind: "member"} with no member_id → 422; an unknown account → 404; a retired account → 409.-
Step 3: Run and watch fail.
go test ./internal/modules/accounting/ -run 'Approver' -count=1→ FAIL (404: no such route). -
Step 4: Implement
approver_handlers.go:
package accounting
// Who approves what is charged to an analytic account (Plan 11, Ruling R8).// The routes sit under /accounting because the resource does; the permission// is the workflow's because the power is (spec P14): choosing an approver is// an authorization change, like publishing a grant.const ( permissionApproverRead = "workflow.definition.read" permissionApproverAssign = "workflow.definition.publish")
type assignApproverBody struct { GranteeKind string `json:"grantee_kind"` PositionID *uuid.UUID `json:"position_id"` MemberID *uuid.UUID `json:"member_id"`}
func (h handlers) liveApprovers(c *gin.Context) { var out []analytic.Approver if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error out, err = analytic.LiveApprovers(ctx, tx, clock.TodayUTC(h.deps.Clock)) return err }); err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, gin.H{"approvers": out})}
func (h handlers) approverHistory(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } var out []analytic.Approver if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { if _, err := analytic.GetAccount(ctx, tx, id); err != nil { return err } var err error out, err = analytic.ApproverHistory(ctx, tx, id) return err }); err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, gin.H{"approvers": out})}
func (h handlers) assignApprover(c *gin.Context) { id, err := parseID(c, "id") if err != nil { _ = c.Error(err) return } var body assignApproverBody if err := decodeStrict(c, &body); err != nil { _ = c.Error(err) return } who, ok := httpx.IdentityFromContext(c.Request.Context()) if !ok { _ = c.Error(apperr.New(apperr.KindUnauthorized, "authentication required")) return } var a analytic.Approver if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { var err error a, err = analytic.AssignApprover(ctx, tx, h.deps.Clock, analytic.AssignInput{ AccountID: id, Kind: body.GranteeKind, PositionID: body.PositionID, MemberID: body.MemberID, Actor: who.MemberID, }) return err }); err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusCreated, a)}analytic.GetAccount does not exist (the package has unexported getAccount). Add an exported one-line wrapper in analytic.go, func GetAccount(ctx context.Context, tx pgx.Tx, id uuid.UUID) (Account, error) { return getAccount(ctx, tx, id) }, rather than renaming the unexported function (its callers stay untouched).
routes.go, with the other analytic routes:
httpx.Protected(g, p, "GET", "/accounting/analytic-approvers", permissionApproverRead, h.liveApprovers) httpx.Protected(g, p, "GET", "/accounting/analytic-accounts/:id/approvers", permissionApproverRead, h.approverHistory) httpx.Protected(g, p, "POST", "/accounting/analytic-accounts/:id/approvers", permissionApproverAssign, h.assignApprover)routes_test.go’s want map gains the three routes with those permissions. errors.go’s mapErr: analytic.ErrApproverShape and analytic.ErrApproverUnknown → apperr.KindValidation (422); check that analytic.ErrAccountInactive already maps to a 409 and ErrNotFound to 404, and add them if not.
-
Step 5: Run.
go test ./internal/modules/accounting/... ./internal/gen/... ./internal/app/... -count=1→ PASS (the drift test proves the generated code matches the YAML;TestEveryRouteIsAuthorizedproves the new routes are protected). -
Step 6: Commit —
API: account_approver grants, sign-offs, and who approves each analytic account.
Task 9: Claims carry their sign-offs; claimants can read what to charge to
Section titled “Task 9: Claims carry their sign-offs; claimants can read what to charge to”Files:
- Create:
internal/modules/expenses/analytic_handlers.go - Modify:
internal/modules/expenses/claim_handlers.go(claimViewDTO.Signoffs,claimDTO.YourShare,listClaims),routes.go,routes_test.go - Test:
internal/modules/expenses/claim_handlers_test.go,analytic_handlers_test.go(new)
Interfaces:
-
Consumes: T6
workflow.Signoffs; T7platformexpenses.YourShares; T8’s schemas. -
Step 1: Write the failing tests. Mirror the module’s existing claim handler tests for setup (they build a router with
expenses.Registerand member tokens).
func TestAClaimViewListsItsSignoffs(t *testing.T)// On a sign-off workflow (as in T7's test), a submitted PA/PB claim's GET /v1/expenses/claims/{id}// has signoffs of length 2 with account_code PA and PB and signed null; after PA's approver signs,// PA's entry has signed.actor_member_id. On the seeded workflow (no sign-off step): signoffs == [].
func TestTheApprovalQueueCarriesTheCallersShare(t *testing.T)// GET /v1/expenses/claims?scope=to_act as PA's approver: the claim's your_share == "60.0000".// As the claimant's superior on the seeded workflow: your_share absent (PRESENCE of the row, ABSENCE of the field).
func TestAnalyticOptionsAreReadableByAClaimant(t *testing.T)// A member holding only expenses.claim.read gets 200 with the project axis and its accounts, names// localized (seed an i18n row for the axis in ms and request with Accept-Language: ms).// A retired axis is absent; a retired account is present with is_active false.// A member without expenses.claim.read: 403.-
Step 2: Run and watch fail.
go test ./internal/modules/expenses/ -run 'Signoff|Share|AnalyticOptions' -count=1→ FAIL. -
Step 3: Implement.
claim_handlers.go:
claimViewDTOgainsSignoffs []workflow.SignoffStatus \json:“signoffs”`; inclaimView`, after the available transitions:
if out.Signoffs, err = workflow.Signoffs(ctx, tx, h.deps.Clock, cl.InstanceID); err != nil { return claimViewDTO{}, err }claimDTOgainsYourShare *money.Amount \json:“your_share,omitempty”`. InlistClaims, on thescope=to_actbranch only, afterToActFiltered`:
shares, err := platformexpenses.YourShares(ctx, tx, h.deps.Clock, who, claims) if err != nil { return err } // ...when building each DTO of this branch: if s, ok := shares[cl.ID]; ok { dto.YourShare = &s }(Name the variables to fit the existing function; who is the caller id that ToActFiltered already receives.)
analytic_handlers.go:
package expenses
type analyticAccountOption struct { ID uuid.UUID `json:"id"` Code string `json:"code"` Name string `json:"name"` ParentID *uuid.UUID `json:"parent_id"` IsActive bool `json:"is_active"`}
type analyticAxisOption struct { ID uuid.UUID `json:"id"` Key string `json:"key"` Name string `json:"name"` IsRequired bool `json:"is_required"` Accounts []analyticAccountOption `json:"accounts"`}
// analyticOptions serves GET /v1/expenses/analytic-options (Plan 11, Ruling// R1): what a claimant may charge a line to. The accounting read of the same// data needs accounting.ledger.read, which a claimant does not hold.func (h handlers) analyticOptions(c *gin.Context) { out := []analyticAxisOption{} if err := h.deps.DB.InTx(c.Request.Context(), func(ctx context.Context, tx pgx.Tx) error { axes, err := analytic.ListAxes(ctx, tx) if err != nil { return err } var ids []uuid.UUID for _, a := range axes { if a.IsActive && slices.Contains(a.AppliesTo, chart.TypeExpense) { ids = append(ids, a.ID) } } names, err := i18n.Load(ctx, tx, "analytic_axis", ids, "name") if err != nil { return err } for _, a := range axes { if !slices.Contains(ids, a.ID) { continue } accounts, err := analytic.ListAccounts(ctx, tx, a.ID) if err != nil { return err } opt := analyticAxisOption{ID: a.ID, Key: a.Key, Name: a.Name, IsRequired: a.IsRequired, Accounts: make([]analyticAccountOption, 0, len(accounts))} if n, ok := names[a.ID]; ok { opt.Name = n } for _, acc := range accounts { opt.Accounts = append(opt.Accounts, analyticAccountOption{ID: acc.ID, Code: acc.Code, Name: acc.Name, ParentID: acc.ParentID, IsActive: acc.IsActive}) } out = append(out, opt) } return nil }); err != nil { _ = c.Error(mapErr(err)) return } c.JSON(http.StatusOK, gin.H{"axes": out})}Check that i18n.Load resolves the request’s locale from ctx the way the accounting module’s labels relies on; if the accounting module passes the locale some other way, do the same here.
routes.go: httpx.Protected(g, p, "GET", "/expenses/analytic-options", PermissionClaimRead, h.analyticOptions); routes_test.go pins it.
-
Step 4: Run.
go test ./internal/modules/expenses/... ./internal/app/... -count=1→ PASS. -
Step 5: Commit —
Claims show their sign-offs and the approver's share; claimants can read analytic options.
Task 10: Screen — Projects and approvers
Section titled “Task 10: Screen — Projects and approvers”Files:
- Create:
web/src/features/accounting/approvers/{api.ts, ApproversPage.tsx, AssignApproverDialog.tsx, routes.tsx, ApproversPage.test.tsx} - Create:
web/src/lib/i18n/locales/{en,ms,zh}/approvers.json - Modify (append only):
web/src/app/shell/nav.ts,web/src/app/routes.ts, and wherever namespaces are registered (grepperiodsunderweb/src/lib/i18n/and registerapproversthe same way)
Interfaces:
-
Consumes:
GET /accounting/analytic-axes(existing,useAnalyticAxesinfeatures/accounting/entries/api.ts), T8’s approver endpoints; the member and position option hooksweb/src/features/workflow/GrantsTable.tsxalready uses for its member and position Comboboxes (reuse them; do not write new ones). -
Produces: route
/accounting/approvers. -
Step 1:
api.ts:
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';import { api } from '@/lib/api/client';import { unwrap } from '@/lib/api/problem';import type { Schemas } from '@/lib/api/types';
export type Approver = Schemas['AnalyticApprover'];
export const approverKeys = { live: ['accounting', 'approvers', 'live'] as const, history: (id: string) => ['accounting', 'approvers', 'history', id] as const,};
export function useLiveApprovers() { return useQuery({ queryKey: approverKeys.live, queryFn: async () => unwrap(await api.GET('/accounting/analytic-approvers')).approvers, });}
export function useApproverHistory(accountId: string | null) { return useQuery({ queryKey: approverKeys.history(accountId ?? ''), enabled: accountId !== null, queryFn: async () => unwrap( await api.GET('/accounting/analytic-accounts/{id}/approvers', { params: { path: { id: accountId! } }, }), ).approvers, });}
export function useAssignApprover(accountId: string) { const qc = useQueryClient(); return useMutation({ mutationFn: async (body: Schemas['AssignAnalyticApproverRequest']) => unwrap( await api.POST('/accounting/analytic-accounts/{id}/approvers', { params: { path: { id: accountId } }, body, }), ), onSuccess: () => qc.invalidateQueries({ queryKey: ['accounting', 'approvers'] }), });}(Match the import paths of api, unwrap and Schemas to what features/accounting/entries/api.ts imports.)
- Step 2: Write the failing page test —
ApproversPage.test.tsx, followingAccountsPage.test.tsx:
const READ = ['accounting.ledger.read', 'workflow.definition.read'];const PUBLISH = [...READ, 'workflow.definition.publish'];
describe('ApproversPage', () => { it('lists each account with its current approver, and "none" where there is none', async () => { // MSW: GET /v1/accounting/analytic-axes → one project axis with PA and PB; // GET /v1/accounting/analytic-approvers → PA → member M ("Mei Ling" via the members fixture). // presence: "Mei Ling" in PA's row; absence of an approver: PB's row shows the "No approver" text. }); it('assigns an approver when the caller may publish', async () => { // PUBLISH: click "Change approver" on PB, pick a member, Save → POST body // {grantee_kind: 'member', member_id}; the dialog closes and the live list is refetched. }); it('offers no change without the publish permission', async () => { // presence under PUBLISH first (the button exists), then with READ only: no "Change approver" button. });});-
Step 3: Run and watch fail.
pnpm -C web test -- src/features/accounting/approvers→ FAIL (module not found). -
Step 4: Implement.
routes.tsx (both permissions: the page lists axes, which is a ledger read, and approvers, which is a workflow read):
import { createRoute } from '@tanstack/react-router';import { appRoute } from '@/app/layouts';import { RequirePermission } from '@/features/auth/session';import { ApproversPage } from './ApproversPage';
const approversRoute = createRoute({ getParentRoute: () => appRoute, path: 'accounting/approvers', component: () => ( <RequirePermission permission="accounting.ledger.read"> <RequirePermission permission="workflow.definition.read"> <ApproversPage /> </RequirePermission> </RequirePermission> ),});
export const routes = [approversRoute] as const;nav.ts (append): { group: 'manage', to: '/accounting/approvers', labelKey: 'approvers:nav', icon: UserCheck, permission: 'workflow.definition.read' }, — UserCheck from lucide-react, as the other icons are imported. routes.ts (append): import and spread routes from @/features/accounting/approvers/routes.
ApproversPage.tsx: PageHeader (title approvers:title, description approvers:intro), then one Card per active axis applying to expense (from useAnalyticAxes()), each with a DataTable<Row> of its accounts — columns code, name, approver (the live approver’s position title or member name, or t('none') in the muted text token), since (valid_from, formatted with the app’s date formatter), and — only when useCan('workflow.definition.publish') — an action column with a Button variant="secondary" “Change approver” opening AssignApproverDialog. Clicking a row opens a Drawer with useApproverHistory rows (who, from, to, withdrawn). Account names are shown as returned (the record-bearing name, as AccountsPage does). Retired accounts are listed after active ones with a Tag “Retired” and no change button.
AssignApproverDialog.tsx: Dialog with RadioCards for “A position” / “A named member” (the two grantee_kind values), then the position Combobox or member Combobox from GrantsTable.tsx’s hooks, and Save → useAssignApprover(accountId).mutate({grantee_kind, position_id | member_id}); server errors shown with problemMessage. The dialog’s text explains P4 in one line: “The change applies from today. The previous approver stays on record.”
approvers.json (en; translate every key for ms and zh, not copies):
{ "nav": "Project approvers", "title": "Who approves each project", "intro": "When a workflow step asks for the approver of a claim's project or cost centre, the person here signs off what is charged to it.", "columns": { "code": "Code", "name": "Name", "approver": "Approver", "since": "Since" }, "none": "No approver", "retired": "Retired", "change": "Change approver", "history": "History", "withdrawn": "Withdrawn", "dialog": { "title": "Change approver for {{code}}", "kind": { "position": "A position", "member": "A named member" }, "note": "The change applies from today. The previous approver stays on record.", "save": "Save" }}-
Step 5: Run.
pnpm -C web test -- src/features/accounting/approvers && pnpm -C web typecheck && pnpm -C web lint→ PASS. -
Step 6: Runtime check against the dev stack (the owner holds every permission): open
/accounting/approvers, assign a position to a project account, see it listed, reopen the history; 390 px and 1280 px, light and dark,enandms. -
Step 7: Commit —
Web: a screen for who approves each project.
Task 11: Screen — charge a claim to projects
Section titled “Task 11: Screen — charge a claim to projects”Files:
- Create:
web/src/features/claims/wizard/ChargeTo.tsx,web/src/features/claims/wizard/__tests__/ChargeTo.test.tsx - Modify:
web/src/features/claims/wizard/wizardSchema.ts,StepInfo.tsx,ClaimWizard.tsx(valuesFrom,toLine),web/src/features/claims/api.ts(one query hook) - Modify:
web/src/lib/i18n/locales/{en,ms,zh}/claimWizard.json(achargeToblock)
Interfaces:
-
Consumes: T9’s
GET /expenses/analytic-options; the existing line body’sshares: { analytic_account_id, percentage }[]. -
Step 1: The query hook in
features/claims/api.ts, besideuseCategories:
export function useAnalyticOptions() { return useQuery({ queryKey: ['expenses', 'analytic-options'], queryFn: async () => unwrap(await api.GET('/expenses/analytic-options')).axes, });}- Step 2: The form field.
wizardSchema.tsgains, in the object and inSTEP_FIELDS[1]:
shares: z.array( z.object({ analytic_account_id: z.string().uuid(), percentage: z.string().regex(/^\d{1,3}(\.\d{1,4})?$/, 'percentage'), }), ),valuesFrom: shares: l?.shares.map((s) => ({ analytic_account_id: s.analytic_account_id, percentage: s.percentage })) ?? []. toLine: shares: v.shares. (The server validates that each axis sums to 100 — the schema stays shape-only, P8.)
- Step 3: Write the failing test —
ChargeTo.test.tsx:
// Renders ChargeTo inside a react-hook-form provider seeded with shares: [] and an MSW// /v1/expenses/analytic-options with a project axis (PA, PB active; PC retired).it('charges a line to one project at 100% by default', ...)// pick PA in the Project select → form value shares == [{PA, '100'}]; PC is not an option.it('splits a line across two projects', ...)// click "Split", pick PB, type 40 in PB's row and 60 in PA's → shares == [{PA,'60'},{PB,'40'}];// the row total reads 100%. Remove PB's row → shares == [{PA,'100'}].it('marks an axis the submission was refused for', ...)// missingAxes={[projectAxisId]} → the Project group shows the chargeTo.missing error text;// with missingAxes={[]} it does not (presence first).-
Step 4: Run and watch fail.
pnpm -C web test -- src/features/claims/wizard/__tests__/ChargeTo→ FAIL. -
Step 5: Implement
ChargeTo.tsx: props{ form: UseFormReturn<WizardValues>; missingAxes: string[] }. For each axis fromuseAnalyticOptions(), a fieldset (legend = axis name, “(required)” whenis_required) holding that axis’s rows ofform.watch('shares')(a share belongs to the axis whose accounts contain itsanalytic_account_id). No row: aSelect“Not charged” → choosing an account adds{id, '100'}. One row: theSelectplus a “Split” link button that adds a second row with an empty account and an empty percentage. Two or more rows: each row is aSelect+ a percentageInput(inputMode="decimal", suffix%) + a removeIconButton, and a line under them showing the typed total. The total is computed with string arithmetic fromweb/src/lib/money.tshelpers — read that file for a decimal-string addition helper, and if there is none, add one there (a BigInt sum over 4-decimal fixed strings) with its own unit test; neverNumber(. Only active accounts are options; an existing share on a retired account stays shown, marked retired.missingAxescontaining the axis id showst('chargeTo.missing', { axis: name })as the fieldset’s error.
In StepInfo.tsx render <div className="md:col-span-2"><ChargeTo form={form} missingAxes={missingAxes} /></div> after the description field; StepInfo takes a new missingAxes prop. In ClaimWizard.tsx, compute it from the draft view’s submit transition (pickSubmitTransition): (submit?.refusal?.gaps ?? []).filter((g) => g.reason === 'unallocated' && g.axis_id).map((g) => g.axis_id!), and pass it down. This is Ruling R3: the engine names the axis, the form marks it.
claimWizard.json gains (translate for ms and zh):
"chargeTo": { "title": "Charge to", "hint": "Which project or cost centre this spend belongs to. Split it if it belongs to more than one.", "none": "Not charged", "split": "Split", "remove": "Remove", "total": "Total {{total}}%", "required": "(required)", "retired": "retired", "missing": "Choose a {{axis}}: this claim's approval needs it." }and errors.percentage: “Enter a percentage, up to 4 decimals.”
-
Step 6: Run.
pnpm -C web test -- src/features/claims && pnpm -C web typecheck && pnpm -C web lint→ PASS (the existingClaimWizard.test.tsxincluded: its fixtures already carryshares: []). -
Step 7: Runtime check: as
employee@demo.test, create a claim charged 60/40 to two projects (create them on the Task 10 screen as owner first), save, reopen — the split is restored; 390 px and 1280 px, both themes,enandms. -
Step 8: Commit —
Web: claim lines can be charged to projects, split if need be.
Task 12: Screen — the rule in the workflow editor, and its preview
Section titled “Task 12: Screen — the rule in the workflow editor, and its preview”Files:
- Modify:
web/src/features/workflow/GrantsTable.tsx,graphModel.ts,RoutingPreview.tsx - Modify:
web/src/lib/i18n/locales/{en,ms,zh}/workflowEditor.json - Test:
web/src/features/workflow/graphModel.test.ts,WorkflowEditorPage.test.tsx
Interfaces:
-
Consumes: T8’s
grantee_axis_id,WorkflowStepRouting.signoffs, gapaxis_id/analytic_account_id;useAnalyticAxes(existing). -
Step 1: Write the failing tests.
graphModel.test.ts:
it('switching a grant to account_approver clears what that kind cannot carry', () => { // start: a position grant with position_id, min_amount '500', min_amount_currency 'MYR' // (PRESENCE of those fields), change kind to account_approver → position_id, min_amount and // min_amount_currency are gone, all_states is false, grantee_axis_id undefined until picked.});it('switching away from account_approver drops the axis', () => { /* ... */ });WorkflowEditorPage.test.tsx (extend the existing file’s setup):
it('offers the project approver rule with an axis picker and no floor', ...)// choose "Approver of the claim's…" → an Axis combobox appears listing the project axis; the// "From amount" field is absent (it is present for the superior grant first — presence).it('previews one row per project on a sign-off step', ...)// MSW routing response: a step with signoffs [PA → members [M], PB → gap no_account_approver].// The preview shows "PA · Project A — Mei Ling" and "PB · Project B — No approver for this account".-
Step 2: Run and watch fail.
pnpm -C web test -- src/features/workflow→ FAIL. -
Step 3: Implement.
GrantsTable.tsx: KINDS gains 'account_approver' (last). In the per-kind fields, a branch for g.grantee_kind === 'account_approver' mirroring the position branch: when useCan('accounting.ledger.read'), a Combobox of useAnalyticAxes() axes that are active and apply to expense, value g.grantee_axis_id; otherwise the t('grants.axisNoAccess') note, as positions do with positionNoAccess. For this kind, do not render the scope choice (thisState / allStates), the floor field, or the bands hint; keep on_vacancy and max_escalation (they govern a vacant approver position). describe(index) gains the case account_approver → t('grants.granteeKinds.account_approver_on', { axis: axisName }).
graphModel.ts, where the grantee kind changes (the block that deletes member_id unless 'member', etc.):
if (kind !== 'account_approver') delete next.grantee_axis_id; if (kind === 'account_approver') { delete next.min_amount; delete next.min_amount_currency; next.all_states = false; }RoutingPreview.tsx: for a step whose signoffs is non-empty, render under its row a nested list, one item per sign-off: ${code} · ${name} then either the members’ names (nameOf) or the gap text (gapText over [s.gap]), and the step’s unallocated gaps as one extra item. Keep the table’s existing columns for other steps.
workflowEditor.json gains (translate for ms and zh):
"granteeKinds": { "account_approver": "The approver of what the claim is charged to", "account_approver_on": "The approver of the claim's {{axis}}" }, "axis": "Charged to", "axisNoAccess": "You need permission to read the chart of accounts to pick an axis.", "signoffNote": "Every account the claim is charged to on this axis needs its own approver's sign-off before the claim moves on."(merged into the existing grants block), and under routing.gapReasons: "unallocated": "Part of the claim is not charged to this axis", "no_account_approver": "No approver for this account".
-
Step 4: Run.
pnpm -C web test -- src/features/workflow && pnpm -C web typecheck && pnpm -C web lint→ PASS. -
Step 5: Runtime check: as owner, in the expense-claim draft, put the new rule alone on
reviewedon the project axis; the violations banner is empty; the preview lists the projects from Task 10 with their approvers; putting a second grant onreviewedshowsaccount_approver_not_aloneinline. Discard the draft afterwards (do not publish on the shared dev stack). Both widths, both themes,enandms. -
Step 6: Commit —
Web: the project-approver rule in the workflow editor and its preview.
Task 13: Screen — the sign-off panel and the approver’s share
Section titled “Task 13: Screen — the sign-off panel and the approver’s share”Files:
- Create:
web/src/features/claims/detail/SignoffPanel.tsx,web/src/features/claims/detail/__tests__/SignoffPanel.test.tsx - Modify:
web/src/features/claims/detail/ClaimDrawer.tsx,web/src/features/approvals/{types.ts, api.ts, ApprovalsPage.tsx} - Modify:
web/src/lib/i18n/locales/{en,ms,zh}/{claimDetail,approvals,common}.json
Interfaces:
-
Consumes: T9’s
ExpenseClaimView.signoffs,ExpenseClaim.your_share; refusalalready_signed. -
Step 1: Write the failing tests.
SignoffPanel.test.tsx:
it('lists each project with its share, and who signed or who is awaited', ...)// signoffs: PA 60.00 signed by Mei Ling (via grant) at a time; PB 40.00 unsigned, eligible [Arif].// "PA · Project A", "MYR 60.00", "Signed by Mei Ling" + time; "PB · Project B", "Waiting for Arif".it('shows a sign-off that nobody can give', ...)// PB with gap no_account_approver → the gap text; no "Waiting for".it('renders nothing when the step has no sign-offs', ...)// signoffs [] → container empty (after asserting the non-empty render above in its own test).ApprovalsPage test (extend the existing one): a claim row with your_share: '60.0000' shows “Your share” MYR 60.00; a row without it shows an em dash in that column.
-
Step 2: Run and watch fail.
pnpm -C web test -- src/features/claims/detail src/features/approvals→ FAIL. -
Step 3: Implement.
SignoffPanel.tsx: props { signoffs: Schemas['WorkflowSignoffStatus'][] }; returns null for an empty list. A section labelled t('signoffs.title') (“Sign-off by project”), a list with one row per sign-off: code · name, <MoneyText amount currency />, then a status: Tag green t('signoffs.signed', { name, when }) when signed (name from useMemberNames(), time with the app’s date-time formatter; append t('signoffs.via.delegation') / via.escalation when signed.via is not grant), else a gap → t(\workflowEditor:routing.gapReasons.${gap.reason}`)in the danger text token, elset(‘signoffs.waiting’, { names })`.
ClaimDrawer.tsx: render <SignoffPanel signoffs={view.signoffs} /> directly after <TransitionBar … />.
approvals/types.ts: QueueItem.yourShare?: string. api.ts toQueueItems: for claims, yourShare: c.your_share ?? undefined. ApprovalsPage.tsx: a yourShare column after amount — <MoneyText amount={row.original.yourShare} currency={row.original.currency} /> or —; the mobile card shows it under the amount when present.
i18n (translate for ms and zh):
-
claimDetail.json:"signoffs": { "title": "Sign-off by project", "signed": "Signed by {{name}}, {{when}}", "waiting": "Waiting for {{names}}", "via": { "delegation": "on delegated authority", "escalation": "escalated" } } -
approvals.json:"columns": { "yourShare": "Your share" }merged into the existingcolumns. -
common.jsonrefusals:"already_signed": "You have already signed off your part of this. It is waiting for the other approvers." -
Step 4: Run.
pnpm -C web test -- src/features/claims src/features/approvals && pnpm -C web typecheck && pnpm -C web lint→ PASS. -
Step 5: Runtime check comes with Task 14’s end-to-end flow (it needs a published sign-off workflow). Here, check the panel against MSW-free dev data only if a sign-off claim already exists; otherwise note it in the report.
-
Step 6: Commit —
Web: the sign-off panel, and the approver's share in the queue.
Task 14: End to end, roadmap, deploy notes, the one full run
Section titled “Task 14: End to end, roadmap, deploy notes, the one full run”Files:
-
Create:
web/e2e/project-approval.spec.ts -
Modify:
web/e2e/seed.ts(two project accounts and their approvers),web/e2e/README.md -
Modify:
docs/ROADMAP.md,docs/DEPLOY_NOTES.md -
Step 1: Seed. In
seed.ts, after the existing steps: find theprojectaxis (GET /accounting/analytic-axes, keyproject— provisioning seeds it), createPA“Project Alpha” andPB“Project Beta” on it (POST /accounting/analytic-axes/{id}/accounts), and assignPA→ theMGRposition (approver@demo.test) andPB→ theFINposition (finance@demo.test) withPOST /accounting/analytic-accounts/{id}/approvers. Idempotent like the rest of the seed (skip what exists). -
Step 2: The flow —
project-approval.spec.ts, in the style ofclaim-flow.spec.ts(as(browser, who),take,stateOf):
// beforeAll (owner, through api('owner')): read the published expense_claim graph; publish a new// version identical except that `reviewed` carries only// {state_key:'reviewed', all_states:false, grantee_kind:'account_approver', grantee_axis_id: projectAxis}.// Use the same draft → publish endpoints workflow-draft.spec.ts drives (read it and web/src/features/workflow/api.ts).// afterAll: publish the original graph back, so later specs file claims on the seeded flow.test('a claim split across two projects waits for both project approvers', async ({ browser }) => { // employee: new claim, charge to Project Alpha 60 / Project Beta 40, submit → "Pending review". // approver (PA): the approvals queue shows the claim with "Your share" MYR 60.00; open it; the // sign-off panel lists both; take "Review" → still "Pending review"; the panel shows PA signed. // The Review button is now disabled with the already_signed text. // finance (PB): take "Review" → "Reviewed". ← the state moves on the second sign-off only // employee: the timeline shows one "Reviewed" decision.});test('a claim with no project is refused at submission while the rule is on', async ({ browser }) => { // employee: a claim with no Charge to → the submit button is disabled; the Project group shows // "Choose a Project: this claim's approval needs it."});-
Step 3: Roadmap and deploy notes.
docs/ROADMAP.md, the Approval by project item: tickDesign specandPlan and build, tick the item, and add its plan link line under it as Plan 10’s has.docs/DEPLOY_NOTES.md, a new section at the top, “Plan 11”:- Migration 00190 adds three tables and a grant kind; its Down refuses while any published grant is
account_approver. workflow.definition.publishnow also governs who approves each analytic account (P14). Holders ofaccounting.account.managecan create projects but not choose their approvers.- Existing workflows are unchanged. The rule takes effect only in a version an administrator publishes with it, and only for claims started after that.
- Migration 00190 adds three tables and a grant kind; its Down refuses while any published grant is
-
Step 4: The one full run.
go test -p 2 -count=1 ./...pnpm -C web typecheck && pnpm -C web lint && pnpm -C web testpnpm -C web e2eExpected: all green. Any failure outside this plan’s files is reported, not worked around.
- Step 5: Commit —
Project approval end to end; roadmap and deploy notes.
Self-review
Section titled “Self-review”- Spec coverage: P1 T6 · P2 T3/T5 (axis, not “project”) · P3 T5 (no workflow per project) · P4 T1/T2 · P5 T2
ApproverChain+ T5 · P6 T3 · P7 T4 · P8 T4signedSince+ T6 · P9 T5 · P10 T5 gaps + T7 test · P11 T6 (authorizeInBandreused) · P12 T3 · P13 T3–T6 (existing tests unchanged) · P14 T8 · §5 T8/T9 · §6 T10–T13 (+ R1’s picker T11) · §7 T1–T14’s load-bearing tests, the Playwright flow in T14. - Names used across tasks:
GranteeAccountApprover,GrantSpec.AxisID(grantee_axis_id),Definition.SignoffGrant,Share,SetShares,Shares,Signed,Subject.{InstanceID,Shares,EntryDecisionID,Signed},SignoffRouting,ResolveSignoffs,PreviewShares,Route(…, shares, on),authorizeInBand,SignAuthority,Authority.{Signs,Completes},RefuseAlreadySigned,Decision.{Pending,Signoffs},Available.Signs,SignShare,SignoffStatus,Signoffs,analytic.{AssignApprover,ApproverHistory,LiveApprovers,ApproverChain,Allocate,GetAccount},expenses.YourShares.
Revision 2 — each project chooses each step’s approver
Section titled “Revision 2 — each project chooses each step’s approver”Why: spec revision 2 (commit 25da95a; decisions marked (r2) in the spec). The steps
are the same for every project; per project, per approval step, the approver can be anyone a
grant can name, with fallback account → ancestors → the step’s default grants. Tasks 1–13
are built on this branch; Task 14 is uncommitted work in progress. Revision 2 reworks what
the change touches and then finishes Task 14 as RT6. Everything not listed stays: shares,
the sign-off engine (recording, completion, delegation/escalation, already_signed), the
charge-to picker, the sign-off panel, the inbox share.
Global Constraints above still bind every RT task, unchanged.
Revision 2 rulings
Section titled “Revision 2 rulings”- RR1 — 00190 is amended in place, not followed by an 00191. It has never left this
branch; a follow-up migration would have to delete existing assignment rows (they have no
step) from a table whose DELETE is revoked for audit reasons. The dev database is rolled
back one step (
go run ./cmd/migrate down, thenup) — its Down succeeds because no published grant isaccount_approver. — Cost if wrong: none outside this branch. - RR2 — The step’s default is its own ordinary grants (the state’s grants other than
the
account_approvermarker,all_statesexcluded per R5). No new column: a project-approval step = marker + zero or more default grants. — Cost if wrong: an administrator who wanted “no default” leaves the step’s other grants empty, which yieldsno_account_approverfor unassigned projects — the conservative outcome. - RR3 — A candidate that resolves to a gap stops there (spec 4.1 r2): a project whose assigned position is retired is refused, it does not silently fall to the default. Only “no assignment at this link” and “only the claimant” move on.
- RR4 — Assignments are keyed by
(analytic_account_id, definition_kind, state_key). State keys survive republishing (the editor keeps them); a step removed from the graph leaves its assignments inert and listed as such. - RR5 — Ending an assignment is
POST /accounting/analytic-accounts/{id}/approvers/endwith{definition_kind, state_key}: it closes the live row (or voids a same-day row) and writes no successor. No DELETE verb: nothing is deleted. - RR6 — The projects screen reads the steps from the published expense-claim
definition (its states carrying a marker, with their labels and default grants), served
by
GET /accounting/analytic-approvers. The accounting module may importinternal/platform/workflow(modules → platform);platform/accounting/analyticmay not (workflow imports analytic).
Revision 2 file structure
Section titled “Revision 2 file structure”db/migrations/00190_project_approval.sql (RT1) analytic_account_approver reshapedinternal/platform/accounting/analytic/approver.go (RT1) per-step, any kind, EndApproverinternal/platform/accounting/analytic/approver_test.go (RT1)internal/platform/workflow/model.go (RT2) SignoffDefaultsinternal/platform/workflow/validate.go, validate_test.go (RT2) not_alone → default floor ruleinternal/platform/workflow/signoff.go, signoff_test.go (RT2) candidates: chain then defaultsinternal/platform/workflow/authorize.go (RT2) ResolveSignoffs callersinternal/platform/workflow/workflowtest/fixture.go (RT2) Approver helpers take a stepapi/openapi.yaml + generated (RT3)internal/modules/accounting/approver_handlers.go (+test) (RT3)internal/modules/accounting/routes.go, routes_test.go (RT3)web/src/features/accounting/approvers/* (RT4) gridweb/src/features/workflow/GrantsTable.tsx (+tests) (RT5) default grants beside the markerweb/src/lib/i18n/locales/{en,ms,zh}/{approvers,workflowEditor}.json (RT4, RT5)web/e2e/*, docs/ROADMAP.md, docs/DEPLOY_NOTES.md (RT6) = Task 14, revisedOrder: RT1 → RT2 → RT3 → RT4 → RT5 → RT6.
Task RT1: Per-step approver assignments, any grantee kind
Section titled “Task RT1: Per-step approver assignments, any grantee kind”Files:
- Modify:
db/migrations/00190_project_approval.sql(theanalytic_account_approvertable only) - Modify:
internal/platform/accounting/analytic/approver.go,approver_test.go,errors.go - Modify:
internal/platform/workflow/signoff.go(only as far as needed to compile against the newApproverChainsignature; RT2 does the real change)
Interfaces:
- Consumes: T2’s approver code (being reshaped).
- Produces:
package analytic
const ( ApproverMember = "member" ApproverPosition = "position" ApproverSuperior = "superior" ApproverUnitHead = "unit_head" ApproverLevelsUp = "levels_up")
type Approver struct { ID uuid.UUID `json:"id"` AccountID uuid.UUID `json:"analytic_account_id"` DefinitionKind string `json:"definition_kind"` StateKey string `json:"state_key"` Kind string `json:"grantee_kind"` PositionID *uuid.UUID `json:"position_id"` MemberID *uuid.UUID `json:"member_id"` LevelsUp *int `json:"levels_up"` ValidFrom time.Time `json:"valid_from"` ValidTo *time.Time `json:"valid_to"` WithdrawnAt *time.Time `json:"withdrawn_at"` CreatedBy uuid.UUID `json:"created_by_member_id"` CreatedAt time.Time `json:"created_at"`}
type AssignInput struct { AccountID uuid.UUID DefinitionKind string StateKey string Kind string PositionID *uuid.UUID MemberID *uuid.UUID LevelsUp *int Actor uuid.UUID}
// ErrApproverNone: EndApprover found no live assignment for that step.var ErrApproverNone = errors.New("analytic: the account has no approver for that step")
func AssignApprover(ctx, tx, c, in AssignInput) (Approver, error)// EndApprover closes (or voids, if it started today) the live assignment for// one step, writing no successor: the step's default applies again.func EndApprover(ctx context.Context, tx pgx.Tx, c clock.Clock, accountID uuid.UUID, definitionKind, stateKey string, actor uuid.UUID) (Approver, error)func ApproverHistory(ctx, tx, accountID uuid.UUID) ([]Approver, error) // every step, newest firstfunc LiveApprovers(ctx, tx, on time.Time) ([]Approver, error) // every account, every step// ApproverChain is the account's live assignment for the step, then each// ancestor's for the same step, nearest first.func ApproverChain(ctx context.Context, tx pgx.Tx, accountID uuid.UUID, definitionKind, stateKey string, on time.Time) ([]Approver, error)- Step 1: Reshape the table in 00190 (Ruling RR1). Replace the
analytic_account_approverdefinition with:
CREATE TABLE analytic_account_approver ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), org_id UUID NOT NULL REFERENCES organization (id), analytic_account_id UUID NOT NULL, -- The step: a state of this kind's workflow graph, by key (Ruling RR4). definition_kind TEXT NOT NULL CONSTRAINT analytic_account_approver_kind_signs CHECK (definition_kind IN ('expense_claim')), state_key TEXT NOT NULL CONSTRAINT analytic_account_approver_state_named CHECK (state_key <> ''), -- Anyone a workflow grant can name (spec P4 r2), shaped as workflow_gate_grant is. grantee_kind TEXT NOT NULL CONSTRAINT analytic_account_approver_grantee_known CHECK (grantee_kind IN ('member', 'position', 'superior', 'unit_head', 'levels_up')), position_id UUID, member_id UUID, levels_up INT, valid_from DATE NOT NULL, valid_to DATE, withdrawn_at TIMESTAMPTZ, created_by_member_id UUID NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), CONSTRAINT analytic_account_approver_ref_matches_kind CHECK ( ((grantee_kind = 'position') = (position_id IS NOT NULL)) AND ((grantee_kind = 'member') = (member_id IS NOT NULL)) AND ((grantee_kind = 'levels_up') = (levels_up IS NOT NULL))), CONSTRAINT analytic_account_approver_levels_bounded CHECK (levels_up IS NULL OR levels_up BETWEEN 1 AND 10), CONSTRAINT analytic_account_approver_dates_ordered CHECK (valid_to IS NULL OR valid_to > valid_from), CONSTRAINT analytic_account_approver_tenant_id UNIQUE (org_id, id), CONSTRAINT analytic_account_approver_account_fk FOREIGN KEY (org_id, analytic_account_id) REFERENCES analytic_account (org_id, id), CONSTRAINT analytic_account_approver_position_fk FOREIGN KEY (org_id, position_id) REFERENCES org_position (org_id, id), CONSTRAINT analytic_account_approver_member_fk FOREIGN KEY (org_id, member_id) REFERENCES organization_member (org_id, id), CONSTRAINT analytic_account_approver_creator_fk FOREIGN KEY (org_id, created_by_member_id) REFERENCES organization_member (org_id, id), -- One live approver per account per step per day. btree_gist exists since 00018. CONSTRAINT analytic_account_approver_one_live EXCLUDE USING gist ( analytic_account_id WITH =, definition_kind WITH =, state_key WITH =, daterange(valid_from, valid_to, '[)') WITH && ) WHERE (withdrawn_at IS NULL));CREATE INDEX analytic_account_approver_account_idx ON analytic_account_approver (org_id, analytic_account_id, definition_kind, state_key);Keep the grants, RLS and the rest of 00190 as they are. Roll the dev database back and forward
if it is running (go run ./cmd/migrate down then go run ./cmd/migrate up, with the dev stack’s
DATABASE_MIGRATE_URL from .dev-stack/env); TestMigration00190 must still pass.
- Step 2: Tests first (
approver_test.go; rewrite the T2 tests to the new shape, keep their presence-before-absence structure):
func TestAssignmentsArePerStep(t *testing.T)// assign PA review → member M1 and PA approve → position P; LiveApprovers has both;// ApproverChain(PA, "expense_claim", "review") is [M1 row] and (…, "approve") is [P row].func TestReassigningOneStepLeavesTheOtherAlone(t *testing.T)// reassign PA review → superior; approve's live row is unchanged; review's old row is closed.func TestAnyGranteeKindCanBeAssigned(t *testing.T)// superior, unit_head, levels_up=2 all succeed; levels_up without a count, a member with a// position id, levels_up=11 → ErrApproverShape (presence: the well-formed one succeeded first).func TestEndingAnAssignmentLeavesNoLiveRow(t *testing.T)// assign then EndApprover → ApproverChain empty for that step, history still has the row// (withdrawn today, since it started today); EndApprover again → ErrApproverNone.func TestApproverChainWalksToTheAncestorForTheSameStep(t *testing.T)// parent P has review → M1; child C none; ApproverChain(C, review) = [P's row];// ApproverChain(C, approve) = [] (P has nothing for approve) — presence before absence.func TestAssigningAStepToAnInactiveAccountIsRefused(t *testing.T) // kept from T2Run go test ./internal/platform/accounting/analytic/... -count=1 → FAIL (compile).
-
Step 3: Implement. In
approver.go:- shape check by kind:
memberneeds onlyMemberID;positiononlyPositionID;levels_uponlyLevelsUpin 1..10;superior,unit_headnone;DefinitionKindmust be"expense_claim"andStateKeynon-empty — elseErrApproverShape; - existence check only for
member(active member) andposition(active position) →ErrApproverUnknown; - the live-row read, close/void and insert filter on
(analytic_account_id, definition_kind, state_key); the account row stays lockedFOR UPDATEfirst, as now; EndApprover: same lock, same close/void of the live row,ErrApproverNonewhen there is none; auditAction: "approver_end"withBeforethe row;ApproverChaingainsdefinitionKind, stateKeyin the join predicate;approverColumnsandscanApprovergaindefinition_kind, state_key, levels_up. Insignoff.go, passdef.Kind/g.StateKeytoApproverChainso it compiles, and map the three new kinds to aGapNoAccountApproverfor now (RT2 replaces it). WatchTestAssignmentsArePerStepfail once by droppingstate_keyfrom the chain’s predicate.
- shape check by kind:
-
Step 4: Run the analytic and workflow packages → PASS;
go build ./... && go vet ./....internal/modules/accounting/approver_handlers.gomust still compile: passDefinitionKind: "expense_claim"and an emptyStateKeyfor now (the shape check refuses it, and its tests are expected to fail until RT3 reworks the API). Say so in the report. -
Step 5: Commit —
Project approvers are per step, and can be anyone a grant can name.
Task RT2: Resolution — the project’s choice for the step, else the step’s default
Section titled “Task RT2: Resolution — the project’s choice for the step, else the step’s default”Files:
- Modify:
internal/platform/workflow/model.go(SignoffDefaults),validate.go,validate_test.go - Modify:
internal/platform/workflow/signoff.go(ResolveSignoffs,resolveAccount,PreviewSharesuntouched),authorize.go(callers),signoff_test.go - Modify:
internal/platform/workflow/workflowtest/fixture.go(ApproverPosition/ApproverMembertake a step; addApproverKind)
Interfaces:
- Consumes: RT1
analytic.ApproverChain(ctx, tx, accountID, definitionKind, stateKey, on),analytic.Approver. - Produces:
// SignoffDefaults is the step's default approvers: the state's own grants other// than its account_approver marker (Ruling RR2; all_states grants excluded, R5).func (d Definition) SignoffDefaults(state string) []Grant
// ResolveSignoffs gains the definition (for its kind and the step's defaults):func ResolveSignoffs(ctx context.Context, tx pgx.Tx, def Definition, g Grant, shares []Share, reporter uuid.UUID, on time.Time) ([]SignoffRouting, []Gap, error)
// workflowtest.World:func (w *World) ApproverPosition(account uuid.UUID, state string, position uuid.UUID)func (w *World) ApproverMember(account uuid.UUID, state string, member uuid.UUID)func (w *World) ApproverKind(account uuid.UUID, state, kind string, levelsUp *int) // superior, unit_head, levels_up-
Step 1: Validation tests first (
validate_test.go):account_approver_not_aloneis removed. New testTestASignoffStepMayCarryDefaultApprovers: the T3signoffGraphplus a position grant on the samereviewstate validates clean.TestADefaultApproverOnASignoffStepHasNoFloor: that default grant withmin_amount→ violation codeaccount_approver_default_flooron the default grant’s ref (presence: withoutmin_amountit validates).- Keep every other
account_approver_*test; watch the new floor rule fail once.
-
Step 2: Resolution tests first (
signoff_test.go; the spec §7 r2 cases):
func TestEachProjectGetsItsOwnApproverOnTheSameStep(t *testing.T)// review has default = position FIN. PRESENCE: with no assignments, PA and PB both resolve to FIN.// Assign PA review → member Ali; PB review → superior. PA → [Ali], PB → [claimant's superior].func TestAProjectsAssignmentIsPerStep(t *testing.T)// graph with two sign-off steps review → approve (both pending, both target-entered, each a marker// on the project axis). PA review → Ali, PA approve → Director position: review resolves to Ali,// approve to the Director's holders.func TestAnUnassignedProjectFallsToItsParentThenTheDefault(t *testing.T)// parent P review → Ali; child C none → C resolves to Ali; end P's assignment → C resolves to the default.func TestNoAssignmentAndNoDefaultIsNoAccountApprover(t *testing.T)// a marker with no default grants and no assignment → gap no_account_approver naming the account// (presence: with a default it routes).func TestABrokenAssignmentIsReportedNotSkipped(t *testing.T)// PA review → position X; retire X → gap position_retired for PA, not the default (Ruling RR3).func TestTheClaimantNeverResolvesToTheirOwnShare(t *testing.T) // T5's P9 test, reworked:// PA review → member = claimant → falls to the default (presence: another claimant gets the member).Keep T5/T6’s other tests, adapting only fixture calls (ApproverPosition(account, "review", pos)).
Run go test ./internal/platform/workflow/... -count=1 → FAIL.
-
Step 3: Implement.
validate.goaccountApprover: delete thenot_aloneloop; add, for each other grantjon the same state (not all_states) withMinAmount != nil,c.add(0, "account_approver_default_floor", fmt.Sprintf("grants[%d]", j), "a project-approval step's default approver has no amount floor").model.goSignoffDefaults: the state’s grants with!AllStates && StateKey == state && GranteeKind != GranteeAccountApprover, with their ids (asSignoffGrantreadsGrantIDs).signoff.goresolveAccount(ctx, tx, org, def, g, r, reporter, on): build the candidate list — eachApproverChain(r.AccountID, def.Kind, g.StateKey, on)row converted to aGrant(kind, position/member/levels copied;OnVacancy,MaxEscalationLevelsandIDfrom the markerg), thendef.SignoffDefaults(g.StateKey)as they are. If the list is empty →GapNoAccountApprover. For each candidate:members, escalated, gap, err := resolveOne(ctx, tx, cand, reporter, on); a gap → report it and stop (RR3);activeOnly; remove the reporter; non-empty → done; empty → next candidate (P9). After the list: the superior fallback exactly as now, elseGapOnlyTheClaimant. Defaults resolve their own positions with their ownOnVacancy(they are ordinary grants).ResolveSignoffstakesdefand passes it down; update its three callers (Route,authorizeSignoff— give itdef— andSignoffs).- Watch
TestEachProjectGetsItsOwnApproverOnTheSameStepfail once by skipping the chain.
-
Step 4: Run
go test ./internal/platform/workflow/... ./internal/modules/workflow/... ./internal/platform/expenses/... -count=1→ PASS;go build ./... && go vet ./.... -
Step 5: Commit —
Each project's approver for the step, else the step's default.
Task RT3: The API for per-step assignments and the projects grid
Section titled “Task RT3: The API for per-step assignments and the projects grid”Files:
- Modify:
api/openapi.yaml; regenerate (make generate) - Modify:
internal/modules/accounting/approver_handlers.go,approver_handlers_test.go,routes.go,routes_test.go,errors.go
Interfaces:
-
Consumes: RT1 (
AssignApprover,EndApprover,ApproverHistory,LiveApprovers,ErrApproverNone);workflow.Latest(ctx, tx, "expense_claim"),Definition.SignoffGrant,SignoffDefaults,ApprovalStates. -
Produces (wire):
AnalyticApprovergainsdefinition_kind,state_key,levels_up(nullable int);grantee_kindenum[member, position, superior, unit_head, levels_up].AssignAnalyticApproverRequest: required[definition_kind, state_key, grantee_kind], plusposition_id,member_id,levels_up.POST /accounting/analytic-accounts/{id}/approvers/end— bodyEndAnalyticApproverRequest {definition_kind, state_key}(both required) → 200AnalyticApprover(the closed row); 404 no live assignment (ErrApproverNone);workflow.definition.publish(RR5).GET /accounting/analytic-approvers→{ approvers: AnalyticApprover[], steps: AnalyticApprovalStep[] }whereAnalyticApprovalStep { definition_kind, state_key, labels: {en?, ms?, zh?} (the state's Labels), axis_id, defaults: WorkflowGrant[] }— one per state of the latest publishedexpense_claimdefinition that carries a marker, in display order (RR6). No published definition or no marker →steps: [].
-
Step 1: Tests first (
approver_handlers_test.go, the existing three reworked plus):TestTheGridListsEachProjectApprovalStepWithItsDefault: publish (viaworkflowtestor the workflow platform) an expense_claim graph with marker + default onreview; GET returns one stepreviewwith the default grant (presence: before publishing,stepsis empty).TestEndingAnAssignmentNeedsThePublishPermission: a publish holder ends it (200); a chart keeper with onlyaccounting.account.managegets 403 — watch it fail once by loosening the route.TestEndingAStepWithNoAssignmentIs404.- The assign test posts
definition_kind,state_key, and asuperiorkind.
-
Step 2: Implement the schemas/paths,
make generate, handlers (reuse the RT1 DTO with dates asYYYY-MM-DD; adddefinition_kind,state_key,levels_up), the route pinned inroutes_test.goatworkflow.definition.publish,ErrApproverNone→ 404 inerrors.go. -
Step 3: Run
go test ./internal/modules/accounting/... ./internal/app/... -count=1,go build ./...,pnpm -C web typecheck(the web will fail typecheck where it used the old shapes — fix only type-level breakage inweb/src/features/accounting/approvers/api.tshere; RT4 rebuilds the screen). -
Step 4: Commit —
API: approvers per step, ending an assignment, and the steps the grid shows.
Task RT4: Screen — the projects × steps grid
Section titled “Task RT4: Screen — the projects × steps grid”Files:
- Modify:
web/src/features/accounting/approvers/{api.ts, ApproversPage.tsx, AssignApproverDialog.tsx, ApproversPage.test.tsx} - Modify:
web/src/lib/i18n/locales/{en,ms,zh}/approvers.json
Interfaces:
-
Consumes: RT3’s wire shapes; the grantee pickers
GrantsTable.tsxuses (member directory, positions, and its grantee-kind options and levels-up input — reuse them). -
Step 1: Tests first (
ApproversPage.test.tsx, MSW):- one card per axis that at least one step names; rows = the axis’s accounts, columns = the
steps (headers from the step’s
labelsin the current language, falling back tostate_key); - a cell with a live assignment shows it (“Ali”, “Director”, “Claimant’s superior”, “2 levels up”);
a cell without one shows “Default:
” (or “No default” when the step has none); - with
workflow.definition.publish, clicking a cell opens the dialog with every grantee kind; saving POSTs{definition_kind, state_key, grantee_kind, …}; a cell with an assignment also offers “Use the default”, which POSTs…/approvers/end; - without the permission, cells are read-only (presence: with it, they are buttons);
steps: []→ an empty state telling the administrator to mark a step in the workflow editor, with a link to it;- history per cell (the drawer from T10, filtered to the cell’s step, still showing from / to / withdrawn).
- one card per axis that at least one step names; rows = the axis’s accounts, columns = the
steps (headers from the step’s
-
Step 2: Implement. Keep T10’s structure; the table becomes the grid; mobile (390 px) shows one card per project listing its steps as rows. i18n in en, ms, zh (genuine Malay).
-
Step 3: Run
pnpm -C web test -- src/features/accounting/approvers, typecheck, lint; runtime check at 390 and 1280 px, light and dark, en and ms — needs a published marker step, so do it in RT6 if none exists and say so. -
Step 4: Commit —
Web: the projects × steps approver grid.
Task RT5: Screen — a project-approval step with its default approvers
Section titled “Task RT5: Screen — a project-approval step with its default approvers”Files:
-
Modify:
web/src/features/workflow/GrantsTable.tsx, its tests,web/src/lib/i18n/locales/{en,ms,zh}/workflowEditor.json -
Step 1: Tests first: on a state carrying an
account_approvermarker, the other grants are shown under a heading “Default approver (for projects that have not chosen one)”; adding a grant there is allowed (presence: before the marker, no such heading); theaccount_approver_not_alonemessage is gone from all three locales andaccount_approver_default_floorhas one; the floor field is hidden for default grants on a marker state;grants.signoffNoteis updated to say that each project chooses this step’s approver on the Projects and approvers screen, and unassigned projects use the default. -
Step 2: Implement, Step 3: Run
pnpm -C web test -- src/features/workflow, typecheck, lint; a quick runtime look at 1280 px en and ms on a draft (do not publish). -
Step 4: Commit —
Web: a project-approval step keeps default approvers.
Task RT6: End to end, roadmap, deploy notes, the one full run (Task 14, revised)
Section titled “Task RT6: End to end, roadmap, deploy notes, the one full run (Task 14, revised)”Task 14 as written above, with these changes. Its uncommitted work in progress in the tree is the starting point: keep what is right, fix the rest.
- Seed: PA “Project Alpha” and PB “Project Beta” on the project axis (as before). Assignments are
per step now, for
expense_claimstatereviewedonly: PA → member approver@demo.test, PB → positionFIN(finance@demo.test). Nothing forapproved, so both projects use that step’s default. Idempotent (skip an assignment that is already live). - Flow publishes a version where
reviewedcarries the marker on the project axis plus its existing grant as the default, andapprovedcarries a marker plus its existing grant as the default. Restore the original in afterAll. - Test 1: a claim split 60/40 PA/PB: at
reviewedit waits for approver@demo.test (PA) and finance@demo.test (PB) — the panel, the inbox share,already_signedas before; atapprovedboth shares fall to the step’s default (the owner): one person signs both shares in one click and the claim moves on. - Test 2: as Task 14’s second test.
- Test 3 (the grid): owner opens Projects and approvers: the
reviewedcolumn shows PA’s and PB’s approvers, theapprovedcolumn shows “Default: …” for both. - Deploy notes: also say that assignments are per step and that an unassigned project uses the step’s default.
- The one full run as in Task 14 Step 4.
- Commit —
Project approval end to end; roadmap and deploy notes.
Revision 2 self-review
Section titled “Revision 2 self-review”- Spec (r2) coverage: P3 — no per-project steps (RT2 resolves within one definition) · P4 r2 RT1 ·
P5 r2 RT2 candidates · P6 r2 RT2 validation · P9 r2 RT2 · P10 r2 RT2
no_account_approveronly with no candidate · P15 unchanged from T3 (R4) · §3.2 r2 RT1 · §4.1 r2 RT2 · §4.4 r2 RT2 · §5 r2 RT3 · §6 r2 RT4/RT5 · §7 r2 RT2 tests + RT6 flow. - Names:
ApproverChain(…, definitionKind, stateKey, on),EndApprover,ErrApproverNone,Definition.SignoffDefaults,ResolveSignoffs(ctx, tx, def, g, …),account_approver_default_floor,AnalyticApprovalStep,EndAnalyticApproverRequest— used consistently RT1–RT6.
Revision 3 — amount bands in a project’s cell
Section titled “Revision 3 — amount bands in a project’s cell”Why: spec revision 3 (commit 9cf348d; decision P16, changes marked (r3)). A project’s cell for a step is a list of bands — a starting amount and one or more approvers, any one of whom signs. The band is chosen by the project’s share; the first band starts at 0; starting amounts strictly increase; an approver appears at most once per band. Everything built in revision 2 stays; only the cell’s shape, its resolution and its screen change. Global Constraints above still bind every BT task.
Revision 3 rulings
Section titled “Revision 3 rulings”- BR1 — A new migration, 00191, not an amendment of 00190. Unlike RR1, 00190 is now applied on dev databases whose published versions carry markers, so its Down is blocked (seen during RT1). 00191 only adds a column and swaps one exclusion constraint; existing rows become one-row cells at band 0 with no data change. — Cost if wrong: one extra file.
- BR2 — On the wire, rows stay flat and each carries
min_amount.AnalyticApprovergainsmin_amount(money string); clients group a cell’s rows by it. Spec §5’s “returned grouped into bands” is met by the row’s field rather than a nested shape, which keeps history (a flat, dated list) unchanged. The spec line is amended with this plan. — Cost if wrong: a later nested DTO, additive. - BR3 — Band selection lives in
analytic(BandFor, pure) andApproverChaintakes the share amount and returns, per level nearest first, only the selected band’s rows.workflownever sees unselected bands. — Cost if wrong: none; the seam is internal. - BR4 — A band is one candidate in
resolveAccount: its rows become the candidate’s sources, soresolveCandidate’s existing rule (a candidate gaps only when every grant in it gaps, with the first’s gap) is exactly spec 4.1 r3’s band rule. No new resolution code beyond building the band. - BR5 —
AssignApproveris replaced bySetApproverCell;EndApproverends the whole cell. Both lock the account row first (as today). A cell that fails P16’s checks isErrApproverBands(422, field error onbands). - BR6 — Between BT3 and BT4 the web type-check is red (the regenerated schema drops
AssignAnalyticApproverRequest). BT3’s generated-code drift test requires regenerating in the same task; BT4 restorespnpm -C web typecheck. No commit between them is merged.
Revision 3 file structure
Section titled “Revision 3 file structure”db/migrations/00191_approver_bands.sql (BT1) min_amount, exclusion per band+approverinternal/platform/workflow/migration_test.go (BT1) 00191 presence/absenceinternal/platform/accounting/analytic/approver.go (BT1) SetApproverCell, EndApprover (cell), BandFor, ApproverChain(amount)internal/platform/accounting/analytic/errors.go (BT1) ErrApproverBandsinternal/platform/accounting/analytic/approver_test.go (BT1)internal/platform/workflow/workflowtest/fixture.go (BT1) ApproverBand helperinternal/platform/expenses/shares_test.go, internal/modules/expenses/claim_handlers_test.go (BT1) callersinternal/platform/workflow/signoff.go, signoff_test.go (BT2) band = one candidateapi/openapi.yaml + internal/gen + web/src/lib/api/schema.d.ts (BT3)internal/modules/accounting/approver_handlers.go, errors.go (+tests) (BT3)web/src/features/accounting/approvers/* (BT4) summary + band dialogweb/src/lib/i18n/locales/{en,ms,zh}/approvers.json (BT4)web/e2e/seed.ts, web/e2e/project-approval.spec.ts (BT5)docs/DEPLOY_NOTES.md, docs/ROADMAP.md (BT5)Order: BT1 → BT2 → BT3 → BT4 → BT5.
Task BT1: Cells of bands — schema and the analytic package
Section titled “Task BT1: Cells of bands — schema and the analytic package”Files:
- Create:
db/migrations/00191_approver_bands.sql - Modify:
internal/platform/accounting/analytic/approver.go,errors.go - Modify:
internal/platform/workflow/workflowtest/fixture.go(helpers) - Modify callers of
AssignApprover:internal/platform/expenses/shares_test.go:219,internal/modules/expenses/claim_handlers_test.go:602,internal/modules/accounting/approver_handlers.go(assignApprover: build a one-band cell from the existing body — the wire changes in BT3),internal/platform/workflow/signoff.go(ApproverChaincall: passr.Amountand take the first row of each level’s band — BT2 replaces this) - Test:
internal/platform/accounting/analytic/approver_test.go,internal/platform/workflow/migration_test.go
Interfaces — Produces:
// Approver gains:MinAmount money.Amount `json:"min_amount"`
type ApproverRef struct { Kind string PositionID *uuid.UUID MemberID *uuid.UUID LevelsUp *int}type BandInput struct { MinAmount money.Amount Approvers []ApproverRef}type CellInput struct { AccountID uuid.UUID DefinitionKind string StateKey string Bands []BandInput Actor uuid.UUID}var ErrApproverBands = errors.New("analytic: a cell's first band starts at 0, bands rise, and each band names each approver once")
func SetApproverCell(ctx context.Context, tx pgx.Tx, c clock.Clock, in CellInput) ([]Approver, error)func EndApprover(ctx context.Context, tx pgx.Tx, c clock.Clock, accountID uuid.UUID, definitionKind, stateKey string, actor uuid.UUID) ([]Approver, error)// BandFor: of one cell's rows, those whose MinAmount is the greatest at or below amount.func BandFor(cell []Approver, amount money.Amount) []Approver// ApproverChain: per level (the account, then each ancestor, nearest first) that has a// live cell for the step, the band BandFor selects for amount. Levels without a cell are skipped.func ApproverChain(ctx context.Context, tx pgx.Tx, accountID uuid.UUID, definitionKind, stateKey string, amount money.Amount, on time.Time) ([][]Approver, error)Fixture: func (w *World) ApproverBand(account uuid.UUID, state string, min string, kind string, position, member *uuid.UUID, levelsUp *int)
inserts one row at min_amount = min; the existing ApproverPosition/Member/Kind keep
inserting at 0.
-
Step 1: Migration test first. Add
TestMigration00191AddsApproverBandsbeside the 00190 one: at 00190,min_amountis absent and a second live row for the same account+step is refused (analytic_account_approver_one_live) — the presence of the old rule; at 00191 the column exists with default 0, two rows of one cell at differentmin_amount(or two approvers at the samemin_amount) are accepted, and the same approver twice at the samemin_amountis refused byanalytic_account_approver_one_live_per_band. Run:go test ./internal/platform/workflow -run Migration00191→ FAIL (no file). -
Step 2: Write
00191_approver_bands.sql.
-- +goose Up-- Spec r3 (P16): a project's cell for a step is a set of bands. One row is-- one approver in one band; the band starts at min_amount.ALTER TABLE analytic_account_approver ADD COLUMN min_amount NUMERIC(19,4) NOT NULL DEFAULT 0 CONSTRAINT analytic_account_approver_min_nonnegative CHECK (min_amount >= 0);ALTER TABLE analytic_account_approver DROP CONSTRAINT analytic_account_approver_one_live;-- One live row per account, step, band and approver per day. The approver's-- identity is its kind plus whichever reference that kind carries.ALTER TABLE analytic_account_approver ADD CONSTRAINT analytic_account_approver_one_live_per_band EXCLUDE USING gist ( analytic_account_id WITH =, definition_kind WITH =, state_key WITH =, min_amount WITH =, grantee_kind WITH =, (COALESCE(position_id::text, member_id::text, levels_up::text, '')) WITH =, daterange(valid_from, valid_to, '[)') WITH && ) WHERE (withdrawn_at IS NULL);
-- +goose DownALTER TABLE analytic_account_approver DROP CONSTRAINT analytic_account_approver_one_live_per_band;ALTER TABLE analytic_account_approver ADD CONSTRAINT analytic_account_approver_one_live EXCLUDE USING gist ( analytic_account_id WITH =, definition_kind WITH =, state_key WITH =, daterange(valid_from, valid_to, '[)') WITH && ) WHERE (withdrawn_at IS NULL);ALTER TABLE analytic_account_approver DROP COLUMN min_amount;If a drift test lists constraint names or migration ranges (search one_live and
00190 under internal/), update it in this task. Run the Step 1 test → PASS.
-
Step 3: Failing analytic tests. In
approver_test.go, rewrite theAssignApprovertests ontoSetApproverCell(one band at 0 reproduces each) and add, each presence-before-absence:TestACellHoldsBandsAndSeveralApproversPerBand— save{0: [Ali]}, {2000: [MeiLing, Director position]};LiveApproversshows 3 rows with thosemin_amounts.TestACellMustStartAtZeroRiseAndNameEachApproverOnce— first save a valid cell (presence: 1 live row); then each of: first band at 100; bands 0, 2000, 2000; bands 0, 1000 then 500 (not rising); a band with no approvers; Ali twice in one band; no bands at all →ErrApproverBands, and the live cell is still the valid one.TestSavingACellReplacesTheWholeCell— cell of 2 bands saved on Jan1 (clock), a 1-band cell saved on Feb1: the old rows are closed at Feb1, one live row; saving twice the same day voids (withdrawn_at) instead.TestEndApproverEndsEveryRowOfTheCell— 2-band cell; End → no live rows for that step, the other step’s cell untouched (presence first).TestBandForPicksTheHighestStartAtOrBelowTheAmount— rows at 0 and 2000: 1999.9999 → band 0; 2000.0000 → band 2000; 0 → band 0 (pure, no DB).TestApproverChainBandsEachLevelByTheSameAmount— child with no cell, parent with bands 0/2000: amount 1500 → parent’s band 0; 3000 → band 2000. Rungo test ./internal/platform/accounting/analytic/...→ FAIL.
-
Step 4: Implement. In
approver.go: addmin_amounttoapproverColumnsandscanApprover; replaceAssignApprover/AssignInputwithSetApproverCell:- org from context;
bandsOK(in.Bands)— len ≥ 1,Bands[0].MinAmount.IsZero(), each next strictly greater (Cmp > 0), each band ≥ 1 approver, no two refs in a band equal (same Kind and same reference) → elseErrApproverBands; - every ref through the existing shape check (
approverShapeOKon anAssignInput-like value; keep the function, taking(definitionKind, stateKey string, r ApproverRef)) →ErrApproverShape; each position/member ref through the existing existence query →ErrApproverUnknown; - lock the account row
FOR UPDATE,ErrNotFound/ErrAccountInactiveas today; liveCell(the oldliveApproverreturning every live row viaqueryApprovers) andcloseOrVoideach;- insert one row per (band, ref) with
min_amount, collecting the results; - one audit row,
Action: "approver",Before: {"approvers": live}(nil when empty),After: {"approvers": inserted}.EndApprover: same lock;liveCell; empty →ErrApproverNone;closeOrVoideach; return the ended rows (withValidTo/WithdrawnAtset as today); auditapprover_endwithBefore: {"approvers": live}.BandFor: scan for the greatestMinAmountwithCmp(amount) <= 0, return the rows at exactly thatMinAmount(nil if none — cannot happen for a saved cell, P16).ApproverChain: keep the recursive query, addup.depthto the select list via a scan of(depth, row)— or order byup.depth, r.min_amountand group consecutive rows byanalytic_account_id— thenBandFor(level, amount)per level. AddErrApproverBandstoerrors.gowith a comment in the file’s style. Update the fixture helpers and the callers listed above (the one-band cell in the handler:[]BandInput{{MinAmount: money.Zero(), Approvers: []ApproverRef{{…from body}}}}, responding withtoApproverDTO(rows[0])). Run:go test ./internal/platform/accounting/analytic/... ./internal/platform/workflow/... ./internal/platform/expenses/... ./internal/modules/accounting/... ./internal/modules/expenses/...→ PASS.
- org from context;
-
Step 5: Commit —
Project approval r3: a cell holds amount bands (schema, analytic).
Task BT2: Resolution — the band for the share is one candidate
Section titled “Task BT2: Resolution — the band for the share is one candidate”Files:
- Modify:
internal/platform/workflow/signoff.go(resolveAccount) - Test:
internal/platform/workflow/signoff_test.go
Interfaces — Consumes: analytic.ApproverChain(ctx, tx, accountID, kind, stateKey, amount, on) ([][]Approver, error); fixture ApproverBand.
-
Step 1: Failing tests (World fixtures as the neighbouring tests use them):
TestTheBandIsChosenByTheShareAtItsBoundary— PA bands 0: Ali, 2000: MeiLing onreviewed. A claim whose PA share is 1999.9999 routes PA to exactly [Ali]; one at 2000.0000 to exactly [MeiLing]. Both assertions (presence of each band).TestTheBandIsChosenByTheShareNotTheClaimTotal— a 3000 claim split 50/50 PA/PB: PA’s 1500 share routes to Ali (the lower band), although the total is above 2000.TestAnyOneOfABandsApproversCompletesTheShare— band 2000: [MeiLing, Rajesh]; both are in the routing’sMembers(presence); MeiLing signs; the share is signed and Rajesh is no longer offered the transition (AvailableTransitionslacks it) and a directTransitionby Rajesh is refused.TestABrokenApproverInABandIsDroppedWhileAnotherRemains— band [retired position, MeiLing]: routes to [MeiLing], no gap; then a band [retired position] alone → thevacantgap (the existing RR3 behaviour, shown in the same test for contrast).TestAnUnassignedProjectUsesItsParentsBandsByItsOwnShare— child with no cell, parent bands 0: Ali, 2000: MeiLing; a child share of 2500 → MeiLing. Rungo test ./internal/platform/workflow -run 'Band|AnyOne|Broken|ParentsBands'→ FAIL.
-
Step 2: Implement. In
resolveAccount, replace the chain loop:
chain, err := analytic.ApproverChain(ctx, tx, r.AccountID, def.Kind, g.StateKey, r.Amount, on) if err != nil { return err } // Each level's band for this share is one candidate (spec 4.1 r3): its // approvers are resolved as grants of their kind on the marker's vacancy // rule, and any one of them may sign. var cands [][]SignoffSource for _, band := range chain { cand := make([]SignoffSource, 0, len(band)) for _, ap := range band { id := ap.ID cand = append(cand, SignoffSource{AssignmentID: &id, Grant: Grant{ID: g.ID, GrantSpec: GrantSpec{ StateKey: g.StateKey, GranteeKind: GranteeKind(ap.Kind), PositionID: ap.PositionID, MemberID: ap.MemberID, LevelsUp: ap.LevelsUp, OnVacancy: g.OnVacancy, MaxEscalationLevels: g.MaxEscalationLevels}}}) } cands = append(cands, cand) }Update resolveAccount’s doc comment (a level’s band, not an assignment). Remove the BT1
stop-gap. recordSignoffs already records each source’s AssignmentID; confirm with
TestASignoffRecordsTheAuthorityItRestsOn and extend it: the signer’s own row id is
recorded when a band has two approvers.
Run go test ./internal/platform/workflow/... → PASS.
-
Step 3: Watch one fail. Temporarily pass
money.Zero()instead ofr.AmounttoApproverChain; the boundary test goes red; restore. -
Step 4: Commit —
Project approval r3: resolve the band the share falls in.
Task BT3: The API — save a whole cell
Section titled “Task BT3: The API — save a whole cell”Files:
- Modify:
api/openapi.yaml(AnalyticApprover, the POST and end operations, schemas) - Regenerate:
make generate(or the repo’s generate target — seeMakefile), producinginternal/gen/api/api.gen.goandweb/src/lib/api/schema.d.ts - Modify:
internal/modules/accounting/approver_handlers.go,errors.go - Test:
internal/modules/accounting/approver_handlers_test.go, route tests if they pin response shapes
Contract:
AnalyticApprover: # add to properties and required min_amount: { type: string, description: The band's starting amount (spec P16), a money string. } AnalyticApproverRef: type: object required: [grantee_kind] additionalProperties: false properties: grantee_kind: { type: string, enum: [member, position, superior, unit_head, levels_up] } position_id: { type: string, format: uuid } member_id: { type: string, format: uuid } levels_up: { type: integer, minimum: 1, maximum: 10 } AnalyticApproverBand: type: object required: [min_amount, approvers] additionalProperties: false properties: min_amount: { type: string, description: 'Money string; the first band is "0".' } approvers: { type: array, minItems: 1, items: { $ref: '#/components/schemas/AnalyticApproverRef' } } SetAnalyticApproverCellRequest: # replaces AssignAnalyticApproverRequest type: object required: [definition_kind, state_key, bands] additionalProperties: false properties: definition_kind: { type: string } state_key: { type: string } bands: { type: array, minItems: 1, items: { $ref: '#/components/schemas/AnalyticApproverBand' } }POST /accounting/analytic-accounts/{id}/approvers (operationId setAnalyticApproverCell)
takes SetAnalyticApproverCellRequest and returns 201 {approvers: AnalyticApprover[]};
…/approvers/end returns 200 {approvers: AnalyticApprover[]}. Permissions unchanged.
- Step 1: Failing handler tests: a two-band cell POST returns 201 with 3 rows and
their
min_amounts (“0.0000”, “2000.0000”); GET live returns them; a cell whose first band is “100” returns 422 with a field error onbandsand the live cell unchanged (save a valid cell first — presence); a malformed amount (“12.3.4”) → 422; end returns the ended rows. Keep the existing permission tests (read vs publish) passing. - Step 2: Implement. Body structs
approverRefBody,bandBody{MinAmount string; Approvers []approverRefBody},setCellBody; parse eachmin_amountwithmoney.Parse(error → 422 field errorbands); callanalytic.SetApproverCell;approverDTOgainsMinAmount stringfroma.MinAmount.String(). Inerrors.go, mapanalytic.ErrApproverBandsto 422 besideErrApproverShape, with its row in the mapping comment table. - Step 3: Regenerate, then
go test ./internal/modules/accounting/... ./internal/gen/...and the generated-code drift test → PASS. The web type-check is expected red until BT4 (BR6). - Step 4: Commit —
Project approval r3: the API saves a whole cell of bands.
Task BT4: Screen — bands in the grid and the dialog
Section titled “Task BT4: Screen — bands in the grid and the dialog”Files:
- Modify:
web/src/features/accounting/approvers/api.ts(useSetApproverCellreplacinguseAssignApprover;Approvernow hasmin_amount) - Modify:
web/src/features/accounting/approvers/ApproversPage.tsx(a cell is the live rows for (account, step); summary) - Modify:
web/src/features/accounting/approvers/AssignApproverDialog.tsx(band rows) - Modify:
web/src/lib/i18n/locales/{en,ms,zh}/approvers.json - Test:
web/src/features/accounting/approvers/ApproversPage.test.tsx
Behaviour:
-
Grid cell: rows grouped by
min_amount(compare as money strings via the existing money helpers inweb/src/lib/money.ts, neverNumber), bands ascending; summaryAli · from RM 2,000: Mei Ling, Director— first band’s names alone, each later bandt('cell.from', { amount })+ names; amount formatted with the app’s existing money formatter and the org currency the page already has. No rows → “Default: …” as today. -
Dialog: state is
Band[] = { minAmount: string; approvers: Ref[] }[], initialised from the cell (or one empty band at “0”). Each band row: a starting-amount input (disabled and “0” on the first; a money input for the rest), a list of approver chips each with a remove button, and an “Add approver” control using the existing kind picker (RadioCards + Combobox/Select, moved into a smallApproverPickerin the same file). “Add band” appends{ minAmount: '', approvers: [] }; each non-first band has “Remove band”. Save is disabled until every band has ≥ 1 approver and every non-first amount is a valid money string strictly greater than the previous (client-side mirror of P16; the server is the authority and its 422 is toasted viaproblemMessage). “Use the default” unchanged. -
i18n keys (all three locales, genuine Malay and Chinese):
cell.from(“from {{amount}}” / “dari {{amount}}” / “{{amount}} 起”),dialog.band.from,dialog.band.add,dialog.band.remove,dialog.approver.add,dialog.approver.remove,dialog.band.rising(the validation hint), and the note updated to say any one approver in a band may sign, chosen by the project’s share. -
Step 1: Failing vitest cases: the grid renders a two-band cell’s summary in order regardless of row order from the API; the dialog opened on it shows two band rows with their approvers; giving the second band an amount of “0” (not above the first) disables Save and shows the hint, and changing it to “2000” enables Save; Save posts
{bands:[{min_amount:"0",approvers:[…]},{min_amount:"2000",approvers:[…,…]}]}(assert the request body via the test’s existing MSW/fetch mock pattern inApproversPage.test.tsx). -
Step 2: Implement. Keep the dialog’s existing structure; extract only the picker.
-
Step 3:
pnpm -C web typecheck && pnpm -C web test -- approvers→ PASS; the i18n key-parity test (if present) → PASS. -
Step 4: Commit —
Project approval r3: the grid and dialog edit bands.
Task BT5: End to end, docs, the one full run
Section titled “Task BT5: End to end, docs, the one full run”Files: web/e2e/seed.ts, web/e2e/project-approval.spec.ts, docs/DEPLOY_NOTES.md,
docs/ROADMAP.md.
- Step 1: Seed.
seedProjectswrites PA’sapprovedcell as two bands through the new POST body: from 0 the owner-seeded member it uses today, from 2000 another seeded approver (e.g. finance@demo.test’s member). Idempotent: skip when the live cell already has those rows. - Step 2: Test 4 (bands): a claim charged 100% to PA for RM 500 waits at
approvedfor the lower-band approver only (the panel names them alone); a second claim for RM 2,500 waits for the upper-band approver only; each signs and the claim moves on. The grid test asserts the PAapprovedcell’s summary shows both bands. - Step 3: Docs. Deploy notes: 00191 is additive, existing cells become one band at 0; the P16 rules; bands are chosen by the project’s share. Roadmap: under Approval by project, add “amount bands per project step” ticked.
- Step 4: The one full run:
go test ./...,pnpm -C web typecheck,pnpm -C web test,pnpm -C web e2eagainst a restarted dev stack (the stack applies 00191 on start). Record results; the three known failures from the expense-date UTC/MYT bug, if the run falls between 00:00 and 08:00 MYT, are reported as such, not fixed here. - Step 5: Commit —
Project approval r3 end to end; deploy notes and roadmap.
Revision 3 self-review
Section titled “Revision 3 self-review”- Spec (r3) coverage: P16 bands/any-one/share/start-at-0/rising/unique — BT1 checks, BT2
resolution, BT4 client mirror · P6 r3 unchanged validation (no code) · §3.2 r3 column +
exclusion + whole-cell save — BT1 · §4.1 r3 band as candidate, gaps within a band — BT2 ·
§5 r3 POST cell — BT3 (grouping per BR2) · §6 r3 summary + band dialog — BT4; sign-off
panel already lists every eligible member (
SignoffPanel.tsxs.eligible) · §7 r3 tests — BT1 (refusal), BT2 (boundary, any one, parent), BT5 (Playwright). - Names:
SetApproverCell,CellInput,BandInput,ApproverRef,BandFor,ApproverChain(…, amount, on) [][]Approver,ErrApproverBands,ApproverBand(fixture),SetAnalyticApproverCellRequest,AnalyticApproverBand,AnalyticApproverRef,useSetApproverCell— used consistently BT1–BT5.