2026-07-26
Building a Generic Attestation Platform for Invoices, Receipts, Payslips, and Financial Workflows
A practical architecture and technical blueprint for configurable, auditable approval workflows across sensitive financial documents.
Part 1: Designing the approval product
Frame the problem before choosing a workflow engine
An attestation platform answers a deceptively simple question: who must examine this document, in what capacity, under which conditions, before another system may act on it? The answer changes with document type, amount, cost center, legal entity, employee, supplier, geography, and time. It also changes when someone is absent, a receipt is coded incorrectly, an invoice is disputed, or a payroll deadline is close.
This article is an architecture study, not a claim that an application was built. It contains no employer-specific process, data, or system detail. Official Fortnox support material inspired questions worth asking about personnel approvals, general supplier invoice settings, initial setup, workflow creation, and day-to-day invoice review. The generic design here is independent, is not Fortnox, and is not claimed to be identical to Fortnox.
The first product decision is scope. Approval is not accounting, payroll calculation, payment execution, expense reimbursement, document interpretation, or records management. It may gate those capabilities, but it should not silently absorb them. The platform should accept a versioned document description, select and freeze an approval plan, record decisions, and publish an outcome. Source and downstream systems remain authoritative for their own domains.
Establish a shared vocabulary
Teams need language that distinguishes a reusable definition from a running case. A template is a versioned policy definition. A rule matches a submitted document to one template version. A workflow instance is the compiled plan for one immutable document version. A step groups tasks with one completion condition. A task is one person's opportunity to decide. A decision is an append-only fact. A delegation grants temporary eligibility, while reassignment changes the task itself.
Some Swedish domain terms are useful when reading the source material. An attestflödesmall is an approval workflow template; leverantörsfakturaattest means supplier invoice approval; personalattest means personnel approval; slutattest means final approval; an ersättningsattestant is a substitute approver; sakattest is substantive approval that goods, services, coding, or facts are correct; and beslutsattest means approval by a person with the authority to authorize the financial decision. These concepts inform vocabulary; they do not prescribe one universal control model.
Model document types without pretending they are the same
Invoices, receipts, payslips, time reports, expense claims, and payment proposals share an approval envelope but carry different risks. An invoice has a supplier, invoice date, due date, currency, tax amounts, lines, purchase references, and possible credit relationships. Duplicate detection and payment blocking matter. A receipt often starts with an employee purchase, imperfect OCR, a card transaction, and coding that may be corrected during review. A payslip contains highly sensitive compensation data, depends on a payroll period, and may need an employee acknowledgement distinct from an employer authorization. A time report is period based and can be unlocked, corrected, and resubmitted. A payment proposal may aggregate already approved liabilities, so its approval must not be confused with approving each underlying invoice.
Use a common document header plus type-specific attributes with schemas that are versioned and validated. Avoid a single table with hundreds of nullable columns, and avoid an ungoverned JSON bag. The approval service needs normalized routing fields and stable amounts, while the full business payload can remain an immutable JSON document version. Store binaries separately.
Separate actors, authority, and visibility
The submitter, document owner, substantive reviewer, budget owner, final approver, investigator, payroll administrator, auditor, tenant administrator, integration service, and emergency operator are different actors even when a small organization assigns several roles to one person. Make separation of duties a policy, not a training note. A submitter might be barred from approving their own receipt. A person who codes an invoice might be allowed to verify delivery but not authorize payment. An administrator who configures templates should not automatically see every payslip.
Role-based access control establishes broad capabilities, such as template editor or auditor. Relationship and attribute rules then ask whether the principal belongs to the tenant, is assigned to the step, manages the relevant employee, owns the cost center, is below an amount ceiling, and is not disqualified by self-approval or prior action. Recompute eligibility on the server for every decision. A task displayed yesterday is not proof of authority today.
Configure policy, then freeze execution
A template should describe supported document types, ordered steps, completion modes, assignee selectors, amount conditions, deadlines, escalation, and separation constraints. Matching rules can use tenant-approved fields such as document type, legal entity, supplier, employee group, cost center, project, currency, and normalized gross amount. Rules need explicit priority, effective dates, deterministic tie handling, and a safe no-match outcome. Hidden precedence creates approval surprises.
Publishing a template creates an immutable version. Submitting a document evaluates rules against one immutable document version, resolves groups and selectors, applies amount thresholds, and compiles a per-document snapshot. That snapshot records the selected template and rule versions, resolved assignees, deadlines, conditions, and relevant policy inputs. Later edits to teams, rules, or templates must not rewrite a running workflow. An authorized migration is a new audited command that explains which instances move, how active tasks are handled, and why.
Steps should support sequential execution, all-of parallel approval, and any-of parallel approval. In an all-of step, every required task must approve. In an any-of step, the first valid approval completes the step and cancels sibling tasks. Amount thresholds can include or omit a step, select a more senior approver, or restrict who may finish. Define currency conversion policy before comparing cross-currency values; do not fetch a changing exchange rate during a decision.
Deadlines belong to the compiled snapshot. A due date can derive from document due date, payroll date, period close, or a fixed duration after activation. Reminders should be configurable, deduplicated, and quiet after completion. Delegation and substitution use half-open windows, [validFrom, validUntil), with a reason, creator, scope, and revocation. They must never expand the delegate's tenant membership, document visibility, amount authority, or separation-of-duties eligibility.
Make lifecycle states explicit
A useful document lifecycle includes draft, submitted, in review, paused or under investigation, rejected, resubmitted, approved, and cancelled. Downstream states should be separate and type-specific, such as export pending, exported, posted, payment pending, paid, payroll finalized, or archived. Approval means the configured workflow completed. It does not prove that accounting entries were posted, money moved, payroll was correct, or a document was lawful.
Draft content may change. Submission seals a document version and starts deterministic matching. Investigation pauses advancement, records an investigator and question, and cancels or suspends reminders without erasing active tasks. Resume returns to the recorded step after a response. Cancellation is terminal for that workflow but does not delete its history.
Rejection requires a useful comment and produces a rejected outcome, not an editable rewind. The owner corrects the source, creates a new document version, and resubmits. The new run links to the rejected run and decides, by explicit policy, whether prior approvals can be referenced or whether all review restarts. For high-risk documents, restarting is the simpler control.
Receipt coding deserves a narrow correction path. An eligible reviewer may propose changes to account, cost center, project, tax treatment, or expense category. The system records old and new values, reason, actor, and timestamp. A material correction creates a new sealed document version and invalidates approvals whose policy inputs changed. Cosmetic metadata can follow a documented non-material rule. Never mutate the evidence behind an existing decision.
Final approval should be an ordinary configured step. Emergency override is different. It needs a dedicated permission, strong reauthentication where available, a mandatory reason, visible warnings about incomplete steps, and an audit event listing bypassed tasks. Consider requiring a second operator above a threshold. An override approves the workflow under an exceptional control; it must not masquerade as the missing reviewers' decisions.
Design for the person with twelve items on a phone
The primary interface is an inbox, not a workflow diagram. It should show why the item is assigned, what decision is needed, deadline, amount and currency, submitter, relevant coding, attachments, prior comments, and changes since the last submission. Filters need predictable semantics. Bulk approval should be limited to homogeneous low-risk items and require a reviewable summary.
Mobile review should support the essential decision safely, but a narrow screen is not a reason to hide tax, coding, or prior rejection context. Large documents need readable previews and a clear route to the original file. Accessibility includes keyboard navigation, logical focus, semantic status text, sufficient contrast, zoom, screen-reader labels, error association, and no color-only meaning. Destructive and override actions need distinct confirmation.
Notifications are hints, not the work queue. Send immediate alerts for newly actionable high-priority items and investigation requests; offer digests for routine work. Use reminders near deadlines and escalation only where policy defines an owner. Every message should avoid sensitive document detail, use a short-lived authenticated link, respect channel preferences, and tolerate duplicate delivery. A user who opens a stale notification should see the current state, not an actionable cached form.
Treat audit, privacy, and operations as product features
The history view should reconstruct who submitted which document version, which rule and template versions matched, how assignees were resolved, every task activation and cancellation, decisions, comments, corrections, delegations, investigations, overrides, downstream acknowledgements, and administrative changes. Record actor identity, tenant, time, request correlation, source channel, and structured before and after facts where appropriate. Audit rows can be chained or exported to restricted storage to make some alteration more detectable, but that is not a truthful basis for calling the history tamper proof.
Minimize data copied into the workflow service. Payslip and receipt data can reveal salary, health-related purchases, travel, location, bank details, and personal identifiers. Apply least privilege to application access, support staff, exports, backups, and logs. Encrypt transport and storage, control keys separately, redact telemetry, and make attachment access short lived. Retention must be policy-driven by tenant, region, record category, and case status. A legal hold suspends normal deletion for a defined scope, with authorization and audit. Deletion should cover database projections, search indexes, object versions, caches, and derived exports according to the validated policy.
Operational governance needs named owners for policy, security, data, integrations, and incident response. Template publication should use review and change notes. Version compatibility, migration rehearsal, reconciliation, and rollback plans belong to every release. Failure scenarios should be designed, not merely tested: duplicate submission, two simultaneous approvals, a revoked approver, a delegation that expires mid-review, an unavailable identity provider, malware in an attachment, object storage failure, a stuck outbox, duplicate events, a notification outage, a downstream rejection, a partial regional outage, and restore from backup.
Build when approval policy is a differentiator, integration boundaries are unusual, or control evidence needs a precise internal model. Buy when a supported product meets policy, jurisdiction, identity, integration, usability, and export requirements at a lower total cost. Include implementation, migration, support, vendor dependency, data portability, security review, and future change in the comparison. A configurable platform is expensive to own because every option becomes a behavior to secure and test.
Deliver in phases. Start with one lower-risk document type, sequential steps, one explicit matching dimension, a reliable inbox, rejection and resubmission, immutable snapshots, and complete history. Add parallel modes and thresholds after concurrency tests. Add delegation, investigation, mobile refinement, integrations, and Kafka only when demand is demonstrated. Add emergency override last, after monitoring, incident procedures, and review ownership exist.
This is technical architecture guidance, not legal, accounting, tax, payroll, labor, records-retention, or regulatory advice, and requirements must be validated with qualified professionals in every applicable jurisdiction.
Part 2: Technical blueprint
Boundaries and assumptions
This blueprint assumes a business-to-business, multi-tenant service with human and service principals, externally supplied document data, and downstream accounting or payroll systems. It assumes one workflow instance approves exactly one immutable document version. It does not calculate payroll, determine tax, execute payment, create accounting truth, validate signatures as legal evidence, or decide retention obligations.
The snippets are representative and unexecuted. Names, limits, schemas, error URIs, and policies are illustrative. Production choices require threat modeling, load testing, database review, identity-provider review, accessibility testing, and jurisdiction-specific validation.
Start as a modular monolith in Spring Boot. Modules should own document intake, policy configuration, workflow compilation, workflow execution, authorization, audit, attachments, notifications, and integrations. Enforce module boundaries in code and tests while sharing one deployment and one PostgreSQL transaction manager. This keeps the approval decision, state transition, audit append, and outbox insert atomic without introducing distributed transaction choreography.
PostgreSQL is the source of truth. Object storage holds immutable binary objects addressed by opaque keys, with checksums and scan state in PostgreSQL. Background workers claim outbox, notification, reminder, scan, and reconciliation work. Kafka is optional as an integration backbone when several consumers, replay, or sustained event volume justify it. A direct outbox worker calling one downstream API may be the better first system.
Tenant isolation
Every tenant-owned row carries tenant_id, and every repository query includes it. Composite foreign keys prevent cross-tenant references. A request resolves the tenant from authenticated membership and an explicit path identifier, never from a tenant claim or request body alone. Row-level security can add defense in depth, but does not replace application checks, composite keys, migration tests, or privileged-role review.
Large or regulated tenants may eventually require a dedicated database, cluster, storage bucket, encryption key, or region. Keep tenant identity in events, object paths, idempotency scope, audit queries, and backup restore tooling so that stronger isolation does not require a domain rewrite.
Representative domain model
| Concept | Responsibility |
|---|---|
| Tenant and membership | Isolation boundary, principal status, and tenant-scoped roles |
| Document and version | Stable identity plus sealed business payload revisions |
| Coding and attachment | Line allocation facts and immutable binary metadata |
| Template version | Published policy definition |
| Rule version | Deterministic template selection |
| Step and assignee version | Ordered completion mode and selector |
| Workflow instance | One compiled plan for one document version |
| Step and task snapshot | Resolved immutable execution units |
| Decision and comment | Append-only human or service facts |
| Delegation | Time-bounded additional eligibility |
| Outbox | Durable integration work committed with domain changes |
| Idempotency record | Request replay protection and stable response |
| Audit event | Restricted chronological security and business history |
Illustrative PostgreSQL 18 schema
This illustrative DDL uses PostgreSQL 18 uuidv7() defaults. Older deployments should generate compliant version 7 UUIDs in the application and bind them explicitly.
create table tenants (
id uuid primary key default uuidv7(),
slug text not null unique,
display_name text not null,
home_region text not null,
status text not null check (status in ('ACTIVE', 'SUSPENDED', 'CLOSED')),
retention_policy_version text not null,
created_at timestamptz not null default now(),
version bigint not null default 0 check (version >= 0)
);
create table principals (
id uuid primary key default uuidv7(),
kind text not null check (kind in ('HUMAN', 'SERVICE')),
issuer text not null,
subject text not null,
display_name text not null,
email_normalized text,
disabled_at timestamptz,
created_at timestamptz not null default now(),
unique (issuer, subject)
);
create table memberships (
id uuid primary key default uuidv7(),
tenant_id uuid not null references tenants(id),
principal_id uuid not null references principals(id),
employee_ref text,
status text not null check (status in ('ACTIVE', 'SUSPENDED', 'ENDED')),
valid_from timestamptz not null,
valid_until timestamptz,
created_at timestamptz not null default now(),
version bigint not null default 0 check (version >= 0),
check (valid_until is null or valid_until > valid_from),
unique (tenant_id, id),
unique (tenant_id, principal_id)
);
create table roles (
id uuid primary key default uuidv7(),
tenant_id uuid not null references tenants(id),
role_key text not null,
description text not null,
created_at timestamptz not null default now(),
unique (tenant_id, id),
unique (tenant_id, role_key)
);
create table membership_roles (
tenant_id uuid not null,
membership_id uuid not null,
role_id uuid not null,
granted_by_membership_id uuid not null,
granted_at timestamptz not null default now(),
valid_until timestamptz,
primary key (tenant_id, membership_id, role_id),
foreign key (tenant_id, membership_id)
references memberships(tenant_id, id),
foreign key (tenant_id, role_id)
references roles(tenant_id, id),
foreign key (tenant_id, granted_by_membership_id)
references memberships(tenant_id, id)
);
create table documents (
id uuid primary key default uuidv7(),
tenant_id uuid not null references tenants(id),
document_type text not null check (
document_type in (
'SUPPLIER_INVOICE', 'RECEIPT', 'PAYSLIP',
'TIME_REPORT', 'EXPENSE_CLAIM', 'PAYMENT_PROPOSAL'
)
),
external_system text not null,
external_ref text not null,
owner_membership_id uuid,
current_version_no integer not null default 0 check (current_version_no >= 0),
state text not null check (
state in (
'DRAFT', 'SUBMITTED', 'IN_REVIEW', 'PAUSED',
'REJECTED', 'RESUBMITTED', 'APPROVED', 'CANCELLED'
)
),
downstream_state text not null default 'NOT_STARTED',
created_by_membership_id uuid not null,
created_at timestamptz not null default now(),
updated_at timestamptz not null default now(),
version bigint not null default 0 check (version >= 0),
unique (tenant_id, id),
unique (tenant_id, external_system, external_ref),
foreign key (tenant_id, owner_membership_id)
references memberships(tenant_id, id),
foreign key (tenant_id, created_by_membership_id)
references memberships(tenant_id, id),
check (
(document_type = 'SUPPLIER_INVOICE' and downstream_state in
('NOT_STARTED', 'EXPORT_PENDING', 'EXPORTED', 'POSTED',
'PAYMENT_PENDING', 'PAID', 'ARCHIVED', 'FAILED')) or
(document_type = 'RECEIPT' and downstream_state in
('NOT_STARTED', 'EXPORT_PENDING', 'EXPORTED', 'POSTED',
'ARCHIVED', 'FAILED')) or
(document_type = 'PAYSLIP' and downstream_state in
('NOT_STARTED', 'PAYROLL_FINALIZED', 'ARCHIVED', 'FAILED')) or
(document_type = 'TIME_REPORT' and downstream_state in
('NOT_STARTED', 'EXPORTED', 'PAYROLL_FINALIZED', 'ARCHIVED', 'FAILED')) or
(document_type = 'EXPENSE_CLAIM' and downstream_state in
('NOT_STARTED', 'EXPORT_PENDING', 'EXPORTED', 'POSTED',
'PAYMENT_PENDING', 'PAID', 'ARCHIVED', 'FAILED')) or
(document_type = 'PAYMENT_PROPOSAL' and downstream_state in
('NOT_STARTED', 'EXPORT_PENDING', 'EXPORTED', 'PAYMENT_PENDING',
'PAID', 'ARCHIVED', 'FAILED'))
)
);
create table document_versions (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
document_id uuid not null,
version_no integer not null check (version_no > 0),
schema_version text not null,
payload jsonb not null check (jsonb_typeof(payload) = 'object'),
currency char(3),
gross_amount numeric(20, 4),
legal_entity_ref text,
supplier_ref text,
employee_ref text,
cost_center_ref text,
content_sha256 bytea not null check (octet_length(content_sha256) = 32),
correction_reason text,
sealed_at timestamptz not null,
sealed_by_membership_id uuid not null,
supersedes_version_id uuid,
unique (tenant_id, id),
unique (tenant_id, document_id, version_no),
unique (tenant_id, id, document_id),
foreign key (tenant_id, document_id)
references documents(tenant_id, id),
foreign key (tenant_id, sealed_by_membership_id)
references memberships(tenant_id, id),
foreign key (tenant_id, supersedes_version_id)
references document_versions(tenant_id, id),
check (
(gross_amount is null and currency is null) or
(gross_amount is not null and currency is not null)
)
);
create table document_line_codings (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
document_version_id uuid not null,
line_no integer not null check (line_no > 0),
account_ref text,
cost_center_ref text,
project_ref text,
tax_code_ref text,
expense_category_ref text,
amount numeric(20, 4) not null,
currency char(3) not null,
source text not null check (source in ('IMPORTED', 'SUBMITTER', 'REVIEWER')),
unique (tenant_id, id),
unique (tenant_id, document_version_id, line_no),
foreign key (tenant_id, document_version_id)
references document_versions(tenant_id, id)
);
create table attachments (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
document_version_id uuid not null,
object_key text not null,
original_filename text not null,
declared_media_type text,
detected_media_type text,
size_bytes bigint not null check (size_bytes between 1 and 52428800),
sha256 bytea not null check (octet_length(sha256) = 32),
scan_state text not null check (
scan_state in ('PENDING', 'CLEAN', 'INFECTED', 'ERROR', 'QUARANTINED')
),
scan_engine_version text,
scanned_at timestamptz,
created_at timestamptz not null default now(),
version bigint not null default 0 check (version >= 0),
unique (tenant_id, id),
unique (tenant_id, object_key),
foreign key (tenant_id, document_version_id)
references document_versions(tenant_id, id)
);
create table workflow_templates (
id uuid primary key default uuidv7(),
tenant_id uuid not null references tenants(id),
template_key text not null,
display_name text not null,
status text not null check (status in ('ACTIVE', 'RETIRED')),
created_at timestamptz not null default now(),
version bigint not null default 0 check (version >= 0),
unique (tenant_id, id),
unique (tenant_id, template_key)
);
create table workflow_template_versions (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
template_id uuid not null,
version_no integer not null check (version_no > 0),
status text not null check (status in ('DRAFT', 'PUBLISHED', 'RETIRED')),
document_types text[] not null,
effective_from timestamptz,
effective_until timestamptz,
source_json jsonb not null check (jsonb_typeof(source_json) = 'object'),
config_sha256 bytea not null check (octet_length(config_sha256) = 32),
change_note text not null,
created_by_membership_id uuid not null,
created_at timestamptz not null default now(),
published_by_membership_id uuid,
published_at timestamptz,
unique (tenant_id, id),
unique (tenant_id, template_id, version_no),
foreign key (tenant_id, template_id)
references workflow_templates(tenant_id, id),
foreign key (tenant_id, created_by_membership_id)
references memberships(tenant_id, id),
foreign key (tenant_id, published_by_membership_id)
references memberships(tenant_id, id),
check (effective_until is null or effective_until > effective_from),
check (
(status = 'PUBLISHED' and published_at is not null and
published_by_membership_id is not null) or
(status <> 'PUBLISHED')
)
);
create table workflow_rule_versions (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
template_version_id uuid not null,
rule_key text not null,
priority integer not null,
predicate_json jsonb not null check (jsonb_typeof(predicate_json) = 'object'),
effective_from timestamptz not null,
effective_until timestamptz,
unique (tenant_id, id),
unique (tenant_id, template_version_id, rule_key),
foreign key (tenant_id, template_version_id)
references workflow_template_versions(tenant_id, id),
check (effective_until is null or effective_until > effective_from)
);
create table tenant_document_selection_policies (
tenant_id uuid not null,
document_type text not null,
no_match_action text not null check (
no_match_action in ('BLOCK_SUBMISSION', 'USE_FALLBACK_TEMPLATE')
),
fallback_template_version_id uuid,
version bigint not null default 0 check (version >= 0),
primary key (tenant_id, document_type),
foreign key (tenant_id) references tenants(id),
foreign key (tenant_id, fallback_template_version_id)
references workflow_template_versions(tenant_id, id),
check (
(no_match_action = 'BLOCK_SUBMISSION' and fallback_template_version_id is null) or
(no_match_action = 'USE_FALLBACK_TEMPLATE' and fallback_template_version_id is not null)
)
);
create table workflow_step_versions (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
template_version_id uuid not null,
step_key text not null,
position integer not null check (position > 0),
completion_mode text not null check (
completion_mode in ('SEQUENTIAL_ONE', 'ALL_OF', 'ANY_OF')
),
purpose text not null,
amount_condition_json jsonb,
deadline_policy_json jsonb not null check (
jsonb_typeof(deadline_policy_json) = 'object'
),
separation_policy_json jsonb not null check (
jsonb_typeof(separation_policy_json) = 'object'
),
unique (tenant_id, id),
unique (tenant_id, template_version_id, step_key),
unique (tenant_id, template_version_id, position),
foreign key (tenant_id, template_version_id)
references workflow_template_versions(tenant_id, id)
);
create table workflow_step_assignee_versions (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
step_version_id uuid not null,
selector_order integer not null check (selector_order > 0),
selector_type text not null check (
selector_type in ('MEMBERSHIP', 'ROLE', 'MANAGER', 'COST_CENTER_OWNER', 'GROUP')
),
selector_json jsonb not null check (jsonb_typeof(selector_json) = 'object'),
required boolean not null default true,
unique (tenant_id, id),
unique (tenant_id, step_version_id, selector_order),
foreign key (tenant_id, step_version_id)
references workflow_step_versions(tenant_id, id)
);
create table workflow_instances (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
document_id uuid not null,
document_version_id uuid not null,
template_version_id uuid not null,
rule_version_id uuid,
selection_mode text not null check (
selection_mode in ('MATCHED_RULE', 'FALLBACK_TEMPLATE')
),
prior_instance_id uuid,
state text not null check (
state in (
'SUBMITTED', 'IN_REVIEW', 'PAUSED', 'REJECTED',
'APPROVED', 'CANCELLED'
)
),
active_step_position integer,
compiled_snapshot jsonb not null check (
jsonb_typeof(compiled_snapshot) = 'object'
),
snapshot_sha256 bytea not null check (octet_length(snapshot_sha256) = 32),
submitted_by_membership_id uuid not null,
submitted_at timestamptz not null,
completed_at timestamptz,
pause_reason text,
version bigint not null default 0 check (version >= 0),
unique (tenant_id, id),
unique (tenant_id, document_version_id),
foreign key (tenant_id, document_id)
references documents(tenant_id, id),
foreign key (tenant_id, document_version_id, document_id)
references document_versions(tenant_id, id, document_id),
foreign key (tenant_id, template_version_id)
references workflow_template_versions(tenant_id, id),
foreign key (tenant_id, rule_version_id)
references workflow_rule_versions(tenant_id, id),
foreign key (tenant_id, prior_instance_id)
references workflow_instances(tenant_id, id),
foreign key (tenant_id, submitted_by_membership_id)
references memberships(tenant_id, id),
check (
(selection_mode = 'MATCHED_RULE' and rule_version_id is not null) or
(selection_mode = 'FALLBACK_TEMPLATE' and rule_version_id is null)
)
);
create table workflow_step_snapshots (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
workflow_instance_id uuid not null,
source_step_version_id uuid not null,
step_key text not null,
position integer not null check (position > 0),
completion_mode text not null check (
completion_mode in ('SEQUENTIAL_ONE', 'ALL_OF', 'ANY_OF')
),
purpose text not null,
state text not null check (
state in ('PENDING', 'ACTIVE', 'COMPLETED', 'CANCELLED', 'BLOCKED')
),
due_at timestamptz,
activated_at timestamptz,
completed_at timestamptz,
version bigint not null default 0 check (version >= 0),
unique (tenant_id, id),
unique (tenant_id, workflow_instance_id, position),
unique (tenant_id, id, workflow_instance_id),
foreign key (tenant_id, workflow_instance_id)
references workflow_instances(tenant_id, id),
foreign key (tenant_id, source_step_version_id)
references workflow_step_versions(tenant_id, id)
);
create table workflow_task_snapshots (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
workflow_instance_id uuid not null,
step_snapshot_id uuid not null,
assigned_membership_id uuid not null,
assignment_basis jsonb not null check (jsonb_typeof(assignment_basis) = 'object'),
required boolean not null,
state text not null check (
state in (
'PENDING', 'ACTIONABLE', 'APPROVED', 'REJECTED',
'CANCELLED', 'SUPERSEDED'
)
),
actionable_from timestamptz,
due_at timestamptz,
decided_at timestamptz,
cancellation_reason text,
version bigint not null default 0 check (version >= 0),
unique (tenant_id, id),
unique (tenant_id, id, workflow_instance_id),
foreign key (tenant_id, step_snapshot_id, workflow_instance_id)
references workflow_step_snapshots(tenant_id, id, workflow_instance_id),
foreign key (tenant_id, assigned_membership_id)
references memberships(tenant_id, id)
);
create unique index one_open_task_per_assignee_and_step
on workflow_task_snapshots
(tenant_id, step_snapshot_id, assigned_membership_id)
where state in ('PENDING', 'ACTIONABLE');
create table workflow_decisions (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
workflow_instance_id uuid not null,
task_snapshot_id uuid,
actor_membership_id uuid not null,
acting_for_membership_id uuid,
decision_type text not null check (
decision_type in (
'APPROVE', 'REJECT', 'PAUSE_FOR_INVESTIGATION',
'RESUME', 'OVERRIDE_APPROVE', 'CANCEL'
)
),
reason_code text,
comment_text text,
policy_evaluation jsonb not null check (
jsonb_typeof(policy_evaluation) = 'object'
),
request_id text not null,
decided_at timestamptz not null default now(),
unique (tenant_id, id),
foreign key (tenant_id, workflow_instance_id)
references workflow_instances(tenant_id, id),
foreign key (tenant_id, task_snapshot_id, workflow_instance_id)
references workflow_task_snapshots(tenant_id, id, workflow_instance_id),
foreign key (tenant_id, actor_membership_id)
references memberships(tenant_id, id),
foreign key (tenant_id, acting_for_membership_id)
references memberships(tenant_id, id),
check (
decision_type <> 'REJECT' or
(comment_text is not null and length(trim(comment_text)) >= 3)
),
check (
decision_type <> 'OVERRIDE_APPROVE' or
(comment_text is not null and length(trim(comment_text)) >= 10)
)
);
create unique index one_terminal_decision_per_task
on workflow_decisions (tenant_id, task_snapshot_id)
where task_snapshot_id is not null
and decision_type in ('APPROVE', 'REJECT');
create table workflow_comments (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
workflow_instance_id uuid not null,
task_snapshot_id uuid,
author_membership_id uuid not null,
visibility text not null check (
visibility in ('WORKFLOW_PARTICIPANTS', 'ADMIN_AND_AUDIT')
),
comment_type text not null check (
comment_type in (
'GENERAL', 'REJECTION', 'INVESTIGATION_QUESTION',
'INVESTIGATION_RESPONSE', 'CORRECTION_NOTE'
)
),
body text not null check (length(trim(body)) > 0),
created_at timestamptz not null default now(),
unique (tenant_id, id),
unique (tenant_id, id, workflow_instance_id),
foreign key (tenant_id, workflow_instance_id)
references workflow_instances(tenant_id, id),
foreign key (tenant_id, task_snapshot_id, workflow_instance_id)
references workflow_task_snapshots(tenant_id, id, workflow_instance_id),
foreign key (tenant_id, author_membership_id)
references memberships(tenant_id, id)
);
create table workflow_investigations (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
workflow_instance_id uuid not null,
paused_step_snapshot_id uuid not null,
opened_by_membership_id uuid not null,
investigator_membership_id uuid not null,
question_comment_id uuid not null,
response_comment_id uuid,
state text not null check (state in ('OPEN', 'ANSWERED', 'RESUMED', 'CANCELLED')),
opened_at timestamptz not null default now(),
answered_at timestamptz,
resumed_at timestamptz,
resumed_by_membership_id uuid,
version bigint not null default 0 check (version >= 0),
unique (tenant_id, id),
foreign key (tenant_id, workflow_instance_id)
references workflow_instances(tenant_id, id),
foreign key (tenant_id, paused_step_snapshot_id, workflow_instance_id)
references workflow_step_snapshots(tenant_id, id, workflow_instance_id),
foreign key (tenant_id, opened_by_membership_id)
references memberships(tenant_id, id),
foreign key (tenant_id, investigator_membership_id)
references memberships(tenant_id, id),
foreign key (tenant_id, question_comment_id, workflow_instance_id)
references workflow_comments(tenant_id, id, workflow_instance_id),
foreign key (tenant_id, response_comment_id, workflow_instance_id)
references workflow_comments(tenant_id, id, workflow_instance_id),
foreign key (tenant_id, resumed_by_membership_id)
references memberships(tenant_id, id),
check (
(state = 'OPEN' and response_comment_id is null) or
(state in ('ANSWERED', 'RESUMED') and response_comment_id is not null) or
state = 'CANCELLED'
),
check ((state <> 'RESUMED') or (resumed_at is not null and resumed_by_membership_id is not null))
);
create unique index one_open_investigation_per_workflow
on workflow_investigations (tenant_id, workflow_instance_id)
where state in ('OPEN', 'ANSWERED');
create table delegations (
id uuid primary key default uuidv7(),
tenant_id uuid not null,
delegator_membership_id uuid not null,
delegate_membership_id uuid not null,
scope_json jsonb not null check (jsonb_typeof(scope_json) = 'object'),
valid_from timestamptz not null,
valid_until timestamptz not null,
reason text not null check (length(trim(reason)) >= 3),
created_by_membership_id uuid not null,
created_at timestamptz not null default now(),
revoked_at timestamptz,
revoked_by_membership_id uuid,
version bigint not null default 0 check (version >= 0),
unique (tenant_id, id),
foreign key (tenant_id, delegator_membership_id)
references memberships(tenant_id, id),
foreign key (tenant_id, delegate_membership_id)
references memberships(tenant_id, id),
foreign key (tenant_id, created_by_membership_id)
references memberships(tenant_id, id),
foreign key (tenant_id, revoked_by_membership_id)
references memberships(tenant_id, id),
check (delegate_membership_id <> delegator_membership_id),
check (valid_until > valid_from),
check (
(revoked_at is null and revoked_by_membership_id is null) or
(revoked_at is not null and revoked_by_membership_id is not null)
)
);
create table outbox_messages (
id uuid primary key default uuidv7(),
tenant_id uuid not null references tenants(id),
aggregate_type text not null,
aggregate_id uuid not null,
aggregate_version bigint not null check (aggregate_version >= 0),
event_type text not null,
event_version integer not null check (event_version > 0),
partition_key text not null,
payload jsonb not null check (jsonb_typeof(payload) = 'object'),
occurred_at timestamptz not null,
available_at timestamptz not null default now(),
aggregate_event_sequence bigint not null check (aggregate_event_sequence > 0),
claim_id uuid,
claimed_at timestamptz,
claim_expires_at timestamptz,
claimed_by text,
published_at timestamptz,
attempt_count integer not null default 0 check (attempt_count >= 0),
last_error_code text,
unique (tenant_id, id),
unique (tenant_id, aggregate_type, aggregate_id, aggregate_event_sequence),
check ((claim_id is null) = (claimed_at is null)),
check ((claim_id is null) = (claim_expires_at is null)),
check (claim_expires_at is null or claim_expires_at > claimed_at)
);
create table idempotency_keys (
id uuid primary key default uuidv7(),
tenant_id uuid not null references tenants(id),
principal_id uuid not null references principals(id),
http_method text not null,
route_key text not null,
idempotency_key text not null,
request_sha256 bytea not null check (octet_length(request_sha256) = 32),
state text not null check (state in ('PROCESSING', 'COMPLETED', 'FAILED')),
response_status integer,
response_headers jsonb,
response_body jsonb,
resource_id uuid,
created_at timestamptz not null default now(),
expires_at timestamptz not null,
version bigint not null default 0 check (version >= 0),
unique (
tenant_id, principal_id, http_method, route_key, idempotency_key
),
check (expires_at > created_at)
);
create table audit_events (
id uuid primary key default uuidv7(),
tenant_id uuid not null references tenants(id),
sequence_no bigint generated always as identity,
actor_principal_id uuid references principals(id),
actor_membership_id uuid,
action text not null,
target_type text not null,
target_id uuid,
request_id text not null,
source_ip inet,
user_agent text,
occurred_at timestamptz not null,
facts jsonb not null check (jsonb_typeof(facts) = 'object'),
previous_hash bytea,
event_hash bytea,
unique (tenant_id, id),
unique (tenant_id, sequence_no),
foreign key (tenant_id, actor_membership_id)
references memberships(tenant_id, id),
check (event_hash is null or octet_length(event_hash) = 32),
check (previous_hash is null or octet_length(previous_hash) = 32)
);
create index documents_inbox_state_idx
on documents (tenant_id, state, updated_at desc, id);
create index document_versions_routing_idx
on document_versions (
tenant_id, legal_entity_ref, supplier_ref,
employee_ref, cost_center_ref, sealed_at desc
);
create index workflow_instances_active_idx
on workflow_instances (tenant_id, state, submitted_at, id)
where state in ('SUBMITTED', 'IN_REVIEW', 'PAUSED');
create index actionable_tasks_inbox_idx
on workflow_task_snapshots (
tenant_id, assigned_membership_id, due_at, id
)
where state = 'ACTIONABLE';
create index active_delegations_idx
on delegations (
tenant_id, delegate_membership_id, valid_from, valid_until
)
where revoked_at is null;
create index outbox_claim_idx
on outbox_messages (aggregate_type, aggregate_id, aggregate_event_sequence, available_at, id)
where published_at is null;
create index audit_target_idx
on audit_events (tenant_id, target_type, target_id, sequence_no);Mutable rows carry versions. Updates use where id = :id and tenant_id = :tenant and version = :expected, increment version, and treat zero rows as a conflict. Decisions, comments, versions, and audit events are append-only in ordinary operation. Aggregate-scoped keys bind document versions, tasks, and investigations to their aggregates. The DDL omits publication triggers and the transaction check tying documents.current_version_no to a real version; constrained repositories and database permissions should enforce them. Domain services still own explainable transitions.
UUIDv7 without version confusion
PostgreSQL stores native UUID values generated inside or outside the database, as its UUID type documentation explains. PostgreSQL 18 adds uuidv7(), documented in UUID functions. Version 7 combines a Unix-epoch millisecond timestamp with version, variant, and random fields specified by RFC 9562.
Time ordering can improve index locality, but is not business sequence or authorization. Use timestamps and aggregate versions. Older releases generate compliant version 7 values in the application.
Source template and compiled snapshot
A source template is editable only in draft and uses stable keys as identity. This example requires one substantive reviewer, every listed budget owner above a threshold, then one eligible finance approver. The selector language must be small, typed, and validated, not an arbitrary expression evaluator.
{
"schemaVersion": "workflow-template/1",
"templateKey": "supplier-invoice-standard",
"documentTypes": ["SUPPLIER_INVOICE"],
"matching": {
"priority": 200,
"all": [
{"field": "legalEntityRef", "operator": "EQ", "value": "entity-se"},
{"field": "grossAmount", "operator": "GTE", "value": "0.00"}
]
},
"steps": [
{
"key": "substance",
"purpose": "VERIFY_SUBSTANCE_AND_CODING",
"mode": "SEQUENTIAL_ONE",
"assignees": [
{"selector": "COST_CENTER_OWNER", "required": true}
],
"deadline": {"basis": "ACTIVATED_AT", "after": "P2D"},
"separation": {"denySubmitter": true}
},
{
"key": "budget",
"purpose": "AUTHORIZE_BUDGET",
"includeWhen": {
"field": "grossAmount",
"operator": "GT",
"value": "10000.00",
"currency": "SEK"
},
"mode": "ALL_OF",
"assignees": [
{"selector": "GROUP", "groupRef": "budget-owners", "required": true}
],
"deadline": {"basis": "DOCUMENT_DUE_DATE", "before": "P3D"},
"separation": {"denySubmitter": true, "denyPriorCoder": true}
},
{
"key": "finance-final",
"purpose": "AUTHORIZE_FINAL_OUTCOME",
"mode": "ANY_OF",
"assignees": [
{
"selector": "ROLE",
"roleKey": "finance-approver",
"maximumAmount": {"value": "50000.00", "currency": "SEK"}
}
],
"deadline": {"basis": "DOCUMENT_DUE_DATE", "before": "P1D"},
"separation": {"denySubmitter": true}
}
],
"notifications": {
"initial": "IMMEDIATE",
"reminders": ["PT24H", "PT48H"],
"digestEligible": true
}
}Compilation reads sealed data, published policy, memberships, assignments, and active substitutions in one consistent transaction. It rejects empty required steps, tied winning rules, invalid currency comparisons, and hard separation violations. Rule ordering is deterministic: the highest priority matching rule wins, and a tie at that priority blocks submission. If no rule matches, the tenant and document-type selection policy either blocks submission or selects its published fallback template version; a caller cannot choose a template. Persist that result as selection_mode = MATCHED_RULE with a nonnull rule_version_id, or selection_mode = FALLBACK_TEMPLATE with a null rule_version_id; the same exclusive choice appears in the snapshot. The canonical result is hashed and explains each assignment, including each selector's required flag. Optional selectors may yield optional tasks, but no required selector may silently become optional or disappear.
{
"schemaVersion": "workflow-snapshot/1",
"workflowInstanceId": "019c9f4d-60da-7bf2-9f6c-24f7a319aa31",
"tenantId": "019c9f30-9422-759b-9cac-6cf62b7c948c",
"document": {
"id": "019c9f47-428d-72fa-873a-35ce258f6560",
"versionId": "019c9f49-aa64-70b6-9ebd-cc27068dd6fe",
"versionNo": 3,
"contentSha256": "sha256:7f1b...4c0e",
"routingFacts": {
"documentType": "SUPPLIER_INVOICE",
"legalEntityRef": "entity-se",
"supplierRef": "supplier-204",
"costCenterRef": "cc-410",
"grossAmount": "21875.50",
"currency": "SEK"
}
},
"selection": {
"selectionMode": "MATCHED_RULE",
"templateId": "019c9f32-f01d-7d67-847f-7803b3d4152d",
"templateVersion": 4,
"ruleKey": "entity-se-default",
"ruleVersionId": "019c9f35-596e-795e-afb3-07ee4594b531",
"explanation": [
"legalEntityRef matched entity-se",
"grossAmount satisfied the lower bound"
]
},
"compiledAt": "2026-07-26T10:15:30.441Z",
"policyClock": "2026-07-26T10:15:30.441Z",
"steps": [
{
"key": "substance",
"position": 1,
"mode": "SEQUENTIAL_ONE",
"dueAt": "2026-07-28T10:15:30.441Z",
"tasks": [
{
"taskId": "019c9f4e-37c8-7022-af53-613f82f78de3",
"membershipId": "019c9f39-abd6-73ac-948e-a3328eedbe02",
"basis": {
"selector": "COST_CENTER_OWNER",
"costCenterRef": "cc-410",
"delegationId": null
},
"required": true
}
]
},
{
"key": "budget",
"position": 2,
"mode": "ALL_OF",
"includedBecause": "21875.50 SEK is greater than 10000.00 SEK",
"tasks": [
{
"taskId": "019c9f4e-975b-75a4-acb4-d9680aac7c05",
"membershipId": "019c9f3b-77ee-75f5-9548-5f9938043166",
"basis": {"selector": "GROUP", "groupRef": "budget-owners"},
"required": true
},
{
"taskId": "019c9f4e-b69c-72c9-91ec-ad88a008ddde",
"membershipId": "019c9f3b-cbc1-71f2-99d9-5b1d475c2363",
"basis": {"selector": "GROUP", "groupRef": "budget-owners"},
"required": true
}
]
},
{
"key": "finance-final",
"position": 3,
"mode": "ANY_OF",
"tasks": [
{
"taskId": "019c9f4f-01d3-730d-97c1-fcd3af6c3b5c",
"membershipId": "019c9f3e-ef74-7040-bb21-3710bd9019ea",
"basis": {"selector": "ROLE", "roleKey": "finance-approver"},
"required": true
},
{
"taskId": "019c9f4f-221b-73d9-936d-3e11679017df",
"membershipId": "019c9f3f-248c-75b7-808d-0c4968ad0af0",
"basis": {"selector": "ROLE", "roleKey": "finance-approver"},
"required": true
}
]
}
],
"snapshotSha256": "sha256:2846...a7df"
}Example hashes are abbreviated. Real hashes require a defined canonical encoding and full digest. Minimize sensitive snapshot fields while keeping routing explanations reconstructable for authorized readers.
State machine and transition rules
Document and workflow states are distinct. Submission seals a version, creates the workflow, and activates review. Rejection and cancellation end that instance. Resubmission creates a new sealed version and linked instance.
| Current state | Command | Required result | Next state |
|---|---|---|---|
| Draft | Submit | Valid version, one deterministic rule, compilable plan | Submitted, then in review |
| Submitted | Activate first step | At least one eligible required task | In review |
| In review | Approve task | Actor eligible, task actionable, precondition current | In review or approved |
| In review | Reject task | Actor eligible, nonempty reason, precondition current | Rejected |
| In review | Open investigation | Authorized actor, durable case, investigator, and question recorded | Paused |
| Paused | Respond to investigation | Configured investigator posts an immutable response | Paused |
| Paused | Resume | The same open investigation is answered, actor authorized, and workflow precondition current | In review |
| Rejected | Resubmit corrected version | New version sealed and policy recompiled | Resubmitted, then in review |
| Draft, submitted, in review, paused | Cancel | Authorized actor and reason | Cancelled |
| In review, paused | Emergency override | Dedicated authority, reason, policy checks | Approved |
| Approved | Downstream acknowledgement | Correlated integration result | Downstream state advances |
Commands, not a generic setState, enforce source state and invariants. Approved, rejected, and cancelled are terminal. A downstream failure changes only downstream state and may require an authorized remediation command.
Investigation pauses the active step, preserves decisions, and creates one durable investigation case that owns its question, designated investigator, response, and resume evidence. Only an ANSWERED open case may resume; a response is immutable and cannot be reused to resume a different case. Cancellation may close an open or answered investigation, retains every immutable question and response comment, and prevents that case from resuming. A content change requires cancellation or rejection followed by a corrected resubmission.
Race handling and idempotency
Any-of and all-of steps make races visible. Suppose two eligible people approve an any-of step at nearly the same time. Both requests may pass an initial read. The command must lock the workflow instance and active step, then re-read the task state. The first transaction records its decision, completes the step, cancels siblings, activates the next step, increments versions, appends audit, and writes outbox messages. The second transaction then sees a cancelled task or stale version and returns a stable conflict rather than a second decision.
For an all-of step, each task approval is unique. While holding the step lock, count remaining required tasks after recording the decision. Complete the step only when none remain actionable or pending. A rejection wins while the step remains active and makes later approval attempts fail. Use deterministic lock order, such as workflow instance, step, then tasks ordered by UUID, to reduce deadlocks.
Optimistic concurrency is suitable for ordinary aggregate updates and exposes stale user interfaces through ETags. SELECT ... FOR UPDATE is suitable for the short critical section that decides a parallel step or claims an outbox row. PostgreSQL documents row locking and SKIP LOCKED in its SELECT reference. SKIP LOCKED is appropriate for queue-like worker tables, not for a user read that must be a consistent view.
Idempotency solves retry ambiguity, not concurrent state changes. For every mutation, scope the key to tenant, principal, method, and normalized route. Store a hash of the canonical request. A repeat with the same key and hash returns the saved status and body. The same key with a different hash returns 409 Conflict. The initial idempotency insert and domain transaction must coordinate so that a crash cannot create an untraceable result. One practical design creates or locks the idempotency row, executes the domain command, stores the response, and commits all database effects together.
REST contract
Publish an OpenAPI description for paths, schemas, security, headers, status codes, examples, and compatibility checks. Review it with server and client changes; generated code does not replace semantic contract tests.
Examples use bearer access tokens, not ID tokens. A valid bounded X-Request-ID is accepted; otherwise the service creates one. Mutations return a version-derived strong ETag, and If-Match protects changes.
Submit
POST /v1/tenants/acme-se/documents/019c9f47-428d-72fa-873a-35ce258f6560/submit HTTP/1.1
Authorization: Bearer eyJ...
Content-Type: application/json
Accept: application/json, application/problem+json
Idempotency-Key: 5d7cb510-e34c-4a54-a5d0-79b51a44943d
If-Match: "document:7"
X-Request-ID: req-01K12FJQ7Q8Z1M6Q5XJFG8EZB2
{
"documentVersionId": "019c9f49-aa64-70b6-9ebd-cc27068dd6fe"
}HTTP/1.1 201 Created
Location: /v1/tenants/acme-se/workflows/019c9f4d-60da-7bf2-9f6c-24f7a319aa31
ETag: "workflow:0"
Content-Type: application/json
{
"workflowId": "019c9f4d-60da-7bf2-9f6c-24f7a319aa31",
"documentId": "019c9f47-428d-72fa-873a-35ce258f6560",
"documentVersionNo": 3,
"state": "IN_REVIEW",
"activeStep": {"key": "substance", "position": 1},
"version": 0
}The server selects the template from sealed routing facts and the tenant's deterministic selection policy; it does not accept requestedTemplateKey. Use 422 Unprocessable Content when the sealed data does not satisfy the documented schema or no safe workflow can compile. Use 409 Conflict for an idempotency-key payload mismatch or an already submitted version. Use 412 Precondition Failed when the document ETag is stale.
Inbox
GET /v1/tenants/acme-se/inbox?state=actionable&limit=25&cursor=eyJ2IjoxLCJzaWciOiIuLi4ifQ HTTP/1.1
Authorization: Bearer eyJ...
Accept: application/json
X-Request-ID: req-01K12FK8M52S8WQD9HW4X8WDN8HTTP/1.1 200 OK
Cache-Control: private, no-store
Content-Type: application/json
{
"items": [
{
"taskId": "019c9f4e-37c8-7022-af53-613f82f78de3",
"taskVersion": 2,
"workflowId": "019c9f4d-60da-7bf2-9f6c-24f7a319aa31",
"document": {
"type": "SUPPLIER_INVOICE",
"displayRef": "INV-20491",
"grossAmount": "21875.50",
"currency": "SEK"
},
"purpose": "VERIFY_SUBSTANCE_AND_CODING",
"assignmentReason": "You own cost center cc-410",
"dueAt": "2026-07-28T10:15:30.441Z",
"allowedActions": ["APPROVE", "REJECT", "INVESTIGATE"]
}
],
"nextCursor": "eyJkdWVBdCI6IjIwMjYtMDctMjhUMTA6MTU6MzAuNDQxWiJ9"
}The opaque cursor is signed, expires quickly, and is bound to the tenant, authenticated membership, normalized query filters, sort definition, and page size. Its payload contains the full exclusive sort key, for example dueAtNullRank, dueAt, and taskId for dueAt ASC NULLS LAST, taskId ASC, plus a hash of the normalized query. The server rejects a cursor used with another membership, tenant, filter, sort, or limit. Inbox queries combine direct and delegated eligibility, then reapply visibility and separation policy. allowedActions guides rendering only; commands recompute authorization.
Approve and reject
POST /v1/tenants/acme-se/tasks/019c9f4e-37c8-7022-af53-613f82f78de3/approve HTTP/1.1
Authorization: Bearer eyJ...
Content-Type: application/json
Idempotency-Key: 9d71e04a-9600-420e-b6b4-f9221d5ee7a5
If-Match: "workflow:4"
{
"comment": "Delivery and coding verified.",
"actingUnderDelegationId": null
}HTTP/1.1 200 OK
ETag: "workflow:5"
Content-Type: application/json
{
"decisionId": "019c9f66-5968-7ed3-b5a5-3cc413ffab37",
"taskState": "APPROVED",
"taskVersion": 3,
"workflowState": "IN_REVIEW",
"workflowVersion": 5,
"nextStep": {"key": "budget", "position": 2}
}POST /v1/tenants/acme-se/tasks/019c9f4e-37c8-7022-af53-613f82f78de3/reject HTTP/1.1
Authorization: Bearer eyJ...
Content-Type: application/json
Idempotency-Key: a3fc3ff9-bf7c-4de0-890c-2fd8080f743a
If-Match: "workflow:4"
{
"reasonCode": "CODING_INCORRECT",
"comment": "Project code is missing on line 2."
}HTTP/1.1 200 OK
ETag: "workflow:5"
Content-Type: application/json
{
"decisionId": "019c9f67-0d30-74bb-812e-023cd16cedd2",
"taskState": "REJECTED",
"workflowState": "REJECTED",
"workflowVersion": 5,
"resubmission": {"requiresNewDocumentVersion": true}
}Approval and rejection both use the workflow ETag because either operation can advance or terminate the workflow. After another person completes an any-of step, approval returns 409 Conflict. A stale workflow ETag returns 412, an ineligible actor receives 403, and a hidden task returns 404 when disclosure would leak information.
Investigate and resume
POST /v1/tenants/acme-se/workflows/019c9f4d-60da-7bf2-9f6c-24f7a319aa31/investigations HTTP/1.1
Authorization: Bearer eyJ...
Content-Type: application/json
Idempotency-Key: 57fb0f8e-c595-4df8-9cbe-7c3557b8b83d
If-Match: "workflow:4"
{
"investigatorMembershipId": "019c9f40-c2ed-7f72-93e7-1a1b80c5fdb9",
"question": "Please confirm whether line 3 belongs to project P-18."
}HTTP/1.1 201 Created
ETag: "workflow:5"
Content-Type: application/json
{
"investigationId": "019c9f6a-7fd9-7162-a506-a65b41777c69",
"workflowState": "PAUSED",
"pausedStep": {"key": "substance", "position": 1}
}POST /v1/tenants/acme-se/investigations/019c9f6a-7fd9-7162-a506-a65b41777c69/response HTTP/1.1
Authorization: Bearer eyJ...
Content-Type: application/json
Idempotency-Key: 4455901b-b6a0-442a-8c09-b01e71e0e1c8
If-Match: "workflow:5"
{
"response": "Line 3 was charged to P-18; the source ledger has been corrected."
}HTTP/1.1 200 OK
ETag: "workflow:6"
Content-Type: application/json
{
"investigationId": "019c9f6a-7fd9-7162-a506-a65b41777c69",
"investigationState": "ANSWERED",
"responseCommentId": "019c9f71-df4f-748d-8f06-036b99a41fb4",
"workflowState": "PAUSED",
"workflowVersion": 6
}POST /v1/tenants/acme-se/workflows/019c9f4d-60da-7bf2-9f6c-24f7a319aa31/resume HTTP/1.1
Authorization: Bearer eyJ...
Content-Type: application/json
Idempotency-Key: 281ed0cc-452c-4253-aa97-20079e2b25b3
If-Match: "workflow:6"
{
"investigationId": "019c9f6a-7fd9-7162-a506-a65b41777c69"
}HTTP/1.1 200 OK
ETag: "workflow:7"
Content-Type: application/json
{
"workflowState": "IN_REVIEW",
"investigationState": "RESUMED",
"workflowVersion": 7,
"activeStep": {"key": "substance", "position": 1},
"remindersRecalculated": true
}Only the configured investigator posts the response unless explicit policy says otherwise. Resume remains with the pausing reviewer or an administrator and atomically changes that answered case to RESUMED; it cannot accept a client-supplied comment ID. The answer is a comment, not a document mutation.
Delegate
POST /v1/tenants/acme-se/delegations HTTP/1.1
Authorization: Bearer eyJ...
Content-Type: application/json
Idempotency-Key: e7315a8e-4724-4d22-b4bb-ac2a40968d4d
{
"delegatorMembershipId": "019c9f39-abd6-73ac-948e-a3328eedbe02",
"delegateMembershipId": "019c9f3a-2700-7103-842b-b23f191755ad",
"validFrom": "2026-08-03T00:00:00Z",
"validUntil": "2026-08-15T00:00:00Z",
"scope": {
"documentTypes": ["SUPPLIER_INVOICE"],
"costCenterRefs": ["cc-410"],
"maximumAmount": {"value": "30000.00", "currency": "SEK"}
},
"reason": "Planned leave"
}HTTP/1.1 201 Created
Location: /v1/tenants/acme-se/delegations/019c9f75-6267-7ee7-9edf-0a0c8c39d72b
ETag: "delegation:0"
Content-Type: application/json
{
"delegationId": "019c9f75-6267-7ee7-9edf-0a0c8c39d72b",
"status": "SCHEDULED",
"version": 0
}The delegation is active when validFrom <= now < validUntil; the end is exclusive, which avoids an ambiguous final second. Reject ambiguous overlapping or cyclic delegations. Define whether they affect current tasks, tasks activated in the window, or both, and record the choice.
Emergency override
POST /v1/tenants/acme-se/workflows/019c9f4d-60da-7bf2-9f6c-24f7a319aa31/override-approval HTTP/1.1
Authorization: Bearer eyJ...
Content-Type: application/json
Idempotency-Key: 2db657ef-3b90-417e-8467-1162fe406864
If-Match: "workflow:8"
{
"reasonCode": "PAYMENT_DEADLINE_INCIDENT",
"reason": "Bank cutoff is imminent after an identity-provider outage.",
"incidentRef": "INC-2026-071",
"stepUpProofId": "019c9f7a-1da2-79cc-9afc-5c9186b606aa",
"acknowledgedIncompleteTaskIds": [
"019c9f4f-01d3-730d-97c1-fcd3af6c3b5c"
]
}HTTP/1.1 200 OK
ETag: "workflow:9"
Content-Type: application/json
{
"decisionId": "019c9f7b-dafe-7442-b2c5-38117904e53d",
"workflowState": "APPROVED",
"approvalMode": "EMERGENCY_OVERRIDE",
"bypassedTaskCount": 1,
"reviewRequired": true
}Keep this endpoint outside ordinary scopes. stepUpProofId is a server-side, single-use proof created only after a validated recent step-up event in the authenticated session or a validated step-up token. Its record binds subject, tenant, required authentication strength, expiry, and intended command, and is consumed atomically with the override; a client-controlled header such as X-Reauthentication-Context is forgeable and is not evidence. Enforce dedicated role, tenant relationship, amount authority, reason, incident reference, alert, and review. High amounts can require a separate second-person state.
History
GET /v1/tenants/acme-se/workflows/019c9f4d-60da-7bf2-9f6c-24f7a319aa31/history?limit=100 HTTP/1.1
Authorization: Bearer eyJ...
Accept: application/jsonHTTP/1.1 200 OK
Cache-Control: private, no-store
Content-Type: application/json
{
"workflowId": "019c9f4d-60da-7bf2-9f6c-24f7a319aa31",
"documentVersionNo": 3,
"snapshotHash": "sha256:2846...a7df",
"events": [
{
"sequence": 17,
"type": "TASK_APPROVED",
"occurredAt": "2026-07-26T11:02:04.119Z",
"actor": {"displayName": "Authorized reviewer"},
"facts": {"stepKey": "substance", "actingUnderDelegation": false}
}
],
"nextCursor": null
}History is an authorized projection, not a raw table dump. Redact policy internals, network data, secrets, personal data, and restricted comments. Richer audit exports need explicit purpose and access logging.
Problem responses
Use the application/problem+json fields defined by RFC 9457. Keep type URIs stable. Details aid correction without exposing SQL, traces, tokens, policy internals, or foreign identifiers.
HTTP/1.1 412 Precondition Failed
Content-Type: application/problem+json
ETag: "workflow:5"
{
"type": "https://api.example.test/problems/stale-version",
"title": "The workflow version is stale",
"status": 412,
"detail": "Refresh the workflow before deciding.",
"instance": "urn:request:req-01K12FQXG8F5ZRCRZSYVYPY4DN",
"currentVersion": 5
}Use status codes consistently:
| Status | Meaning in this API |
|---|---|
200 OK |
Mutation completed and returns current result |
201 Created |
Workflow, investigation, or delegation created |
202 Accepted |
Explicit asynchronous command accepted, with operation resource |
204 No Content |
Idempotent administrative removal or revocation completed |
400 Bad Request |
Malformed syntax or headers |
401 Unauthorized |
Missing or invalid bearer token |
403 Forbidden |
Authenticated principal lacks permitted action |
404 Not Found |
Resource absent or intentionally undisclosed |
409 Conflict |
Current domain state conflicts or idempotency key reused differently |
412 Precondition Failed |
If-Match version no longer current |
415 Unsupported Media Type |
Request content type not accepted |
422 Unprocessable Content |
Well-formed input violates documented domain validation |
429 Too Many Requests |
Rate or resource quota exceeded |
Representative Spring command path
Spring declarative transactions support method-level transaction behavior and rollback rules. The official Spring transaction documentation also cautions against transactions spanning remote calls. Keep the command transaction short: load and lock database rows, authorize against current facts, transition the aggregate, append decisions and audit, write outbox work, then commit. Object storage, Kafka, email, malware engines, webhooks, and downstream APIs stay outside it.
The following Java-like examples are representative pseudocode and were not compiled or executed.
@Component
final class TenantAuthorization {
private final MembershipRepository memberships;
private final PolicyEngine policy;
void requireTaskAction(
AuthenticatedPrincipal principal,
TenantId tenantId,
TaskSnapshot task,
Action action,
Instant now
) {
Membership actor = memberships
.findActive(tenantId, principal.stablePrincipalId(), now)
.orElseThrow(NotFoundOrForbidden::new);
if (!task.tenantId().equals(tenantId)) {
throw new NotFoundOrForbidden();
}
PolicyResult result = policy.evaluate(
actor,
task,
action,
memberships.activeDelegationsFor(actor.id(), now),
now
);
if (!result.allowed()) {
throw new ForbiddenProblem(result.publicReasonCode());
}
}
}The stable principal ID maps the validated issuer and subject pair to an internal principal. Email address, display name, and mutable group claims are not durable identity keys.
@Service
final class ApprovalApplicationService {
private final WorkflowRepository workflows;
private final TenantAuthorization authorization;
private final DecisionRepository decisions;
private final AuditRepository audit;
private final OutboxRepository outbox;
private final Clock clock;
@Transactional(rollbackFor = Exception.class)
ApprovalResult approve(ApproveTaskCommand command, RequestContext request) {
Instant now = clock.instant();
Workflow workflow = workflows.lockByTask(
command.tenantId(),
command.taskId()
).orElseThrow(NotFoundOrForbidden::new);
Task task = workflow.requireActionableTask(command.taskId());
authorization.requireTaskAction(
request.principal(),
command.tenantId(),
task,
Action.APPROVE,
now
);
PolicyEvaluation evidence = workflow.recomputeEligibility(
request.principal().stablePrincipalId(),
command.actingUnderDelegationId(),
now
);
Decision decision = workflow.approve(
task.id(),
evidence.actorMembershipId(),
evidence.actingForMembershipId(),
command.comment(),
request.requestId(),
now
);
workflows.saveWithExpectedVersion(workflow, command.expectedWorkflowVersion());
decisions.append(decision);
audit.append(AuditEvent.from(workflow, decision, request));
for (DomainEvent event : workflow.releaseEvents()) {
outbox.append(OutboxMessage.from(event));
}
return ApprovalResult.from(workflow, task, decision);
}
}The repository lock can issue SELECT ... FOR UPDATE for the workflow and active step. saveWithExpectedVersion still verifies the aggregate version, protecting callers that reached the service through a stale ETag. The unique decision index is a final database guard, not the primary race algorithm.
The outbox worker claims a small batch and commits the claim before remote work. A production worker needs claim expiry, heartbeats or bounded processing time, backoff, poison-message handling, and metrics. It must not claim event sequence 8 for an aggregate while an unpublished sequence 7 exists, even if another aggregate has work available.
record OutboxClaim(UUID id, UUID claimId, UUID tenantId, String partitionKey,
long aggregateEventSequence, JsonNode payload) {}
@Repository
final class OutboxClaims {
@Transactional
List<OutboxClaim> claim(String workerId, int batchSize, Instant now) {
return jdbc.query("""
with candidates as (
select o.id
from outbox_messages o
where o.published_at is null
and o.available_at <= :now
and (
o.claim_expires_at is null or
o.claim_expires_at < :now
)
and not exists (
select 1 from outbox_messages earlier
where earlier.tenant_id = o.tenant_id
and earlier.aggregate_type = o.aggregate_type
and earlier.aggregate_id = o.aggregate_id
and earlier.published_at is null
and earlier.aggregate_event_sequence < o.aggregate_event_sequence
)
order by o.occurred_at, o.id
for update skip locked
limit :batchSize
)
update outbox_messages o
set claim_id = :claimId,
claimed_at = :now,
claim_expires_at = :claimExpiry,
claimed_by = :workerId,
attempt_count = attempt_count + 1
from candidates c
where o.id = c.id
returning o.id, o.claim_id, o.tenant_id, o.partition_key,
o.aggregate_event_sequence, o.payload
""", parameters(workerId, batchSize, now, UUID.randomUUID(),
now.plus(claimLease)));
}
@Transactional(readOnly = true)
boolean hasCurrentLease(UUID id, UUID claimId, String workerId, Instant now) {
return jdbc.queryForObject("""
select exists (
select 1 from outbox_messages
where id = :id
and claim_id = :claimId
and claimed_by = :workerId
and claim_expires_at > :now
and published_at is null
)
""", Boolean.class, parameters(id, claimId, workerId, now));
}
}
@Component
final class OutboxPublisher {
void publishBatch() {
List<OutboxClaim> claims = claims.claim(workerId(), 100, clock.instant());
for (OutboxClaim claim : claims) {
try {
if (!claims.hasCurrentLease(
claim.id(), claim.claimId(), workerId(), clock.instant())) {
continue;
}
kafka.send(
"attestation.events.v1",
claim.partitionKey(),
claim.payload()
).get(publishTimeout);
completion.markPublishedInNewTransaction(
claim.id(), claim.claimId(), workerId(), clock.instant());
} catch (RetryableException failure) {
completion.rescheduleInNewTransaction(
claim.id(), claim.claimId(), workerId(), clock.instant(), failure.code());
}
}
}
}kafka.send occurs after the claim transaction commits. Immediately before it, the worker checks that it owns a nonexpired lease. Completion and rescheduling require id, claim_id, claimed_by, claim_expires_at > :now, and published_at is null, so an expired or superseded claim cannot complete another lease. A worker that needs longer renews before expiry and stops if renewal fails. A lease expiry or failure after the broker accepts a send but before completion commits remains ambiguous: a later claim can send the event again. Leases coordinate attempts and eligible order; they do not prove exactly-once external publication. Attachment scanning, email, and webhooks use the same short-transaction boundary.
Authentication and authorization
OpenID Connect is an identity layer for authenticating an end user and returning an ID Token to a client. OIDC Core defines that model. OAuth bearer access tokens authorize calls to the attestation API. An ID Token proves an authentication result to its intended client; it should not be accepted as an API access token merely because it is a JWT.
For an interactive web application, use an OIDC authorization flow appropriate to the client and provider, maintain the user session securely, and call the API with an access token intended for this resource server. Service accounts use a non-human OAuth grant or workload identity supported by the authorization server. Give them distinct principals, narrow scopes, tenant membership, credential rotation, and no interactive emergency permissions.
Spring Security's JWT resource server documentation covers signature, issuer, timestamp, and audience validation. Configure an allowlisted issuer and accepted signing algorithms. Validate the signature against current trusted keys, iss, intended aud, exp, and nbf, with a small documented clock skew. Reject tokens with an unexpected issuer, algorithm, audience, or token type. Bound token and header size. Key refresh failure needs observable caching behavior and a runbook, not disabled validation.
@Configuration
@EnableMethodSecurity
class ApiSecurityConfiguration {
@Bean
JwtDecoder jwtDecoder(SecurityProperties properties) {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withIssuerLocation(properties.issuer())
.jwsAlgorithm(RS256)
.build();
OAuth2TokenValidator<Jwt> issuerAndTime =
JwtValidators.createDefaultWithIssuer(properties.issuer());
OAuth2TokenValidator<Jwt> audience =
jwt -> jwt.getAudience().contains(properties.audience())
? OAuth2TokenValidatorResult.success()
: OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "Required audience missing", null)
);
decoder.setJwtValidator(
new DelegatingOAuth2TokenValidator<>(issuerAndTime, audience)
);
return decoder;
}
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.requestCache(cache -> cache.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/actuator/health").permitAll()
.requestMatchers("/v1/**").authenticated()
.anyRequest().denyAll())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
.build();
}
}This filter chain accepts only bearer access tokens for /v1/**, creates no authenticated browser session, and does not authenticate from cookies. Disabling CSRF is limited to that bearer-only API chain. Cookie-authenticated browser endpoints require a separate chain with CSRF protection enabled and appropriate token handling. @EnableMethodSecurity is required for @PreAuthorize to be enforced.
Token scopes authorize broad API capabilities. Tenant RBAC grants roles such as submitter, reviewer, policy editor, auditor, integration writer, or override operator. Relationship and attribute policy narrows those capabilities for the concrete resource. A controller annotation alone is not enough:
@PreAuthorize("hasAuthority('SCOPE_attestation.decide')")
@PostMapping("/v1/tenants/{tenant}/tasks/{task}/approve")
ResponseEntity<ApprovalResponse> approve(
@PathVariable String tenant,
@PathVariable UUID task,
@RequestHeader("If-Match") String ifMatch,
@RequestHeader("Idempotency-Key") String idempotencyKey,
@Valid @RequestBody ApproveBody body,
JwtAuthenticationToken authentication
) {
TenantId tenantId = tenantResolver.requireMembership(
tenant,
authentication.getToken().getIssuer(),
authentication.getToken().getSubject()
);
return responses.from(
idempotency.execute(
tenantId,
authentication,
idempotencyKey,
body,
() -> approvals.approve(
commands.from(tenantId, task, ifMatch, body),
requestContext.from(authentication)
)
)
);
}Step eligibility is always recomputed inside the command transaction from current membership, delegation window, relationship, amount limit, prior decisions, and separation policy. Removing a person or revoking a delegation takes effect even if a page remains open. Whether membership removal also causes active task reassignment is a separate, audited operational command.
Apply least privilege at every layer: API scopes, tenant roles, repository methods, database roles, object-store policies, Kafka ACLs, support tooling, backup access, and key use. Separate policy editors from policy publishers if risk warrants it. Prevent self-approval, incompatible role combinations, and one person satisfying multiple steps where policy requires independent actors.
Break-glass operation needs a dedicated path. Keep accounts or entitlements limited, monitored, regularly reviewed, and unusable for routine work. Require strong authentication, current incident reference, narrow time window, explicit target tenant, and post-use review. Log denied attempts as well as successful overrides. Never share a generic administrator account.
Attachments, sensitive data, and audit integrity
Use short-lived tenant-bound upload grants or a size-limited streaming API; generate object keys server-side, treat filenames as display data, and verify type structurally. Quarantine each object until checksum, type detection, and malware scan succeed. Preview from an isolated origin, force download for risky types, and sandbox image, PDF, and archive processing with resource limits.
Encrypt transport, storage, backups, and exports; separate key administration; and test key rotation and restore. Restrict sensitive projections, search, analytics, support access, and telemetry. The audit ledger needs restricted append access, tenant sequence, contextual facts, reconciliation, and optional independently checkpointed hashes, but is not tamper proof when one attacker controls the application, database, keys, and checkpoints.
Use controls informed by the OWASP API Security Top 10: object and function authorization on every route, bounded resource use, schema validation, safe outbound destinations, inventory of API versions, rate and quota controls, secure configuration, and careful integration trust. Add CSRF protection when a browser uses cookies, strict CORS for known origins, request-body limits, safe error handling, dependency and image scanning, and secret rotation. This is an OWASP-minded design, not a claim of certification or complete security.
Outbox, Kafka, and integration semantics
The domain transaction writes an outbox row beside every integration-relevant change. A publisher later sends it to Kafka or a direct downstream adapter, then marks the row published. A crash after send but before that mark produces a duplicate. Design for at-least-once delivery.
Kafka documents at-most-once, at-least-once, and exactly-once concepts and warns that the details span publishing and consuming. See the current versioned official Kafka 4.3 delivery-semantics documentation. Kafka producer idempotence and transactions can strengthen behavior within supported Kafka flows, but they do not make a PostgreSQL update, object-store write, email, webhook, and arbitrary downstream database one end-to-end exactly-once transaction. The outbox plus idempotent consumers is the honest boundary.
Use the workflow or document ID as the Kafka record key so events for that aggregate reach one partition and preserve broker order within that partition. The publisher only makes sequence 8 eligible after sequence 7 is marked published, but lease expiry and the send-before-completion ambiguity can still produce duplicate sends. Ordering across different documents is neither promised nor needed. Include event ID, event type, event schema version, aggregate version, aggregate event sequence, tenant ID, occurrence time, correlation ID, and a minimal payload. Keep personal or financial content out unless a named consumer requires it and the topic controls are approved.
{
"eventId": "019c9f83-6ca4-72ef-823b-65cdac71328f",
"eventType": "attestation.workflow.approved",
"eventVersion": 1,
"occurredAt": "2026-07-26T12:20:44.593Z",
"tenantId": "019c9f30-9422-759b-9cac-6cf62b7c948c",
"aggregate": {
"type": "WORKFLOW",
"id": "019c9f4d-60da-7bf2-9f6c-24f7a319aa31",
"version": 9,
"eventSequence": 12
},
"document": {
"id": "019c9f47-428d-72fa-873a-35ce258f6560",
"versionId": "019c9f49-aa64-70b6-9ebd-cc27068dd6fe",
"type": "SUPPLIER_INVOICE"
},
"outcome": {
"mode": "STANDARD",
"approvedAt": "2026-07-26T12:20:44.581Z"
},
"correlationId": "req-01K12G9RS3YWFQE7M44X4Q98FQ"
}An investigation event can stay equally small:
{
"eventId": "019c9f84-2d38-727c-bf1a-5001ac33d823",
"eventType": "attestation.workflow.paused",
"eventVersion": 1,
"occurredAt": "2026-07-26T12:23:41.105Z",
"tenantId": "019c9f30-9422-759b-9cac-6cf62b7c948c",
"aggregate": {
"type": "WORKFLOW",
"id": "019c9f4d-60da-7bf2-9f6c-24f7a319aa31",
"version": 5,
"eventSequence": 8
},
"reasonCode": "INVESTIGATION_OPENED",
"correlationId": "req-01K12GAGY0Y2PKT5T5XDCBJ9TE"
}Each consumer stores processed event IDs and the last contiguous aggregate sequence with its local effect. Event-ID deduplication handles retries; the sequence detects gaps and old deliveries. Only lastContiguous + 1 may apply. A duplicate ID is a no-op, an old sequence with a new ID is quarantined, and a future sequence blocks that aggregate until an authoritative read or reconciliation recovers the gap. Consumers must tolerate duplicates and recover gaps; no lease scheme can promise their prevention after an external-send ambiguity. Do not merely log a financial-outcome gap and continue.
Treat schemas as contracts. Add optional fields compatibly, never change the meaning or type of an existing field in place, and retain old readers during rollout. A breaking change uses a new event version or event type with a documented coexistence window. Validate producer and consumer schemas in CI, keep representative fixtures, and test replay from retained topics or an approved archive.
Downstream acknowledgements return through an idempotent API or event carrying the original event ID, document version, and downstream reference. Update only the downstream state. A rejection such as an invalid account code should not rewrite the approved workflow; it creates an operational remediation case and may require a corrected source version and new approval according to policy.
Notifications and scheduled work
Notifications use durable jobs, approved channel fields, retries, and application deduplication. Digests read current tasks at send time; reminder workers recheck state, eligibility, delegation, and preference. Cancellation events remove queued reminders idempotently, and notification failure never rolls back approval.
Observability, SLOs, alerts, and runbooks
Measure the system around user outcomes and queue health:
- API request rate, latency, status, and precondition conflicts by route template
- submission compilation duration and failure reason
- actionable task age, overdue task count, and inbox query latency
- approval, rejection, investigation, cancellation, and override counts
- authorization denials by stable public reason code
- outbox oldest-unpublished age, attempts, and publish latency
- Kafka consumer lag, deduplication count, gap detection, and dead-letter count
- notification queue age, delivery success, retries, and permanent failures
- attachment scan age, result, parser failure, and quarantine size
- database pool saturation, lock wait, deadlock, replica lag, storage, and backup age
- downstream acknowledgement age and reconciliation mismatch count
Use traces across HTTP handling, database work, outbox publication, consumers, and downstream calls with request, event, and correlation IDs. Do not attach document payloads, token claims, comments, filenames, or full tenant names. Logs should be structured, bounded, redacted, and sampled without dropping security-relevant audit facts.
The following service-level objectives are illustrative examples, not measured claims:
| Illustrative objective | Example target and measurement |
|---|---|
| Interactive API availability | 99.9 percent of eligible monthly requests are not server failures |
| Decision latency | 99 percent of valid approval commands complete within 1 second |
| Inbox latency | 95 percent of first-page inbox reads complete within 500 milliseconds |
| Event freshness | 99 percent of committed outbox events publish within 60 seconds |
| Reminder freshness | 99 percent of due reminders are evaluated within 10 minutes |
| Recovery point | No more than 15 minutes of committed database data at risk |
| Recovery time | Restore the regional service within 4 hours |
Choose targets from business impact, architecture, and tested capability. Exclude planned maintenance only through a written policy. Publish the measurement query and error-budget rules so that a percentage is not just decoration.
Alert on sustained user-facing error rate, growing oldest-task or outbox age, exhausted notification retries, scan backlog, authorization-denial anomalies, override use, failed backups, restore-test failure, database saturation, Kafka consumer gaps, and reconciliation mismatches. Avoid one alert per failed message. Page for conditions needing immediate action and ticket slower degradation.
Runbooks should identify owner, impact, dashboards, safe diagnostics, containment, recovery, communication, and verification. Essential runbooks cover identity-provider failure, database failover, object-storage outage, stuck locks, poison outbox event, consumer gap, notification provider outage, malware detection, accidental policy publication, suspected cross-tenant access, emergency override review, backup restore, and regional evacuation.
Testing the behaviors that matter
Use an executable state-machine model and generated command sequences to prove terminality, pause behavior, one decision per task, and completion of every required compiled condition.
Property tests generate templates, documents, assignees, amounts, delegations, and command orderings. Useful properties include:
- compilation is deterministic for identical versioned inputs and policy clock
- no required compiled step is empty
- an any-of step has at most one winning terminal decision
- an all-of step cannot complete with an undecided required task
- a rejected instance never later becomes approved
- delegation never grants authority beyond the delegator and delegate intersection
- a material document correction never retains an approval against changed evidence
- idempotent replay returns the same observable response and creates no extra decision
Run barrier tests on PostgreSQL for same-task, parallel-step, reject-versus-approve, resume-versus-cancel, delegation-revocation, and claim-expiry races; assert rows, versions, events, siblings, and responses. Test the authorization matrix, cross-tenant substitution, migrations, object and identity controls, Kafka deduplication, and contracts. Failure injection covers transaction, publish, consumer, notification, storage, database, identity, and queue failures. Fuzz API boundaries and test token validation, accessibility, and full submit-to-acknowledgement journeys.
Deployment, migration, recovery, and reconciliation
Package API and workers from one reviewed revision, use separate database roles, keep secrets outside the image, and verify pinned, scanned, traceable artifacts.
Use expand and contract database migrations:
- Add nullable columns, new tables, indexes built with an operationally safe method, and dual-read capability.
- Deploy code that writes both old and new representations when necessary.
- Backfill in bounded, resumable batches with progress and reconciliation.
- Switch reads behind a tenant or percentage rollout flag.
- Stop old writes only after all running versions are compatible.
- Validate backups and replica behavior.
- Remove old columns or behavior in a later release.
Treat template changes as reviewed business migrations. Roll out by cohort without allowing two interpretations of one snapshot, and define rollback, compatibility, and replay plans. Recovery requires tested database and object backups, keys, identity configuration, templates, schemas, infrastructure, and isolated restore drills. Declare regional dependencies honestly.
Reconciliation is routine, not only an incident activity. Representative read-only queries include:
-- Active workflow with no actionable task and no deliberate pause.
select wi.tenant_id, wi.id, wi.state, wi.active_step_position
from workflow_instances wi
where wi.state = 'IN_REVIEW'
and not exists (
select 1
from workflow_step_snapshots ss
join workflow_task_snapshots ts
on ts.tenant_id = ss.tenant_id
and ts.step_snapshot_id = ss.id
where ss.tenant_id = wi.tenant_id
and ss.workflow_instance_id = wi.id
and ss.state = 'ACTIVE'
and ts.state = 'ACTIONABLE'
);
-- Approved workflow whose durable approval event is absent.
select wi.tenant_id, wi.id, wi.version
from workflow_instances wi
where wi.state = 'APPROVED'
and not exists (
select 1
from outbox_messages om
where om.tenant_id = wi.tenant_id
and om.aggregate_type = 'WORKFLOW'
and om.aggregate_id = wi.id
and om.event_type = 'attestation.workflow.approved'
);
-- Outbox backlog grouped by age and attempts.
select
date_trunc('hour', now() - occurred_at) as backlog_age,
attempt_count,
count(*) as messages
from outbox_messages
where published_at is null
group by 1, 2
order by 1 desc, 2 desc;
-- Attachment metadata that references no clean, accessible object result.
select a.tenant_id, a.id, a.object_key, a.scan_state, a.scanned_at
from attachments a
where a.scan_state in ('PENDING', 'ERROR', 'QUARANTINED')
and a.created_at < now() - interval '30 minutes';
-- Terminal task without a matching terminal decision.
select ts.tenant_id, ts.id, ts.state
from workflow_task_snapshots ts
where ts.state in ('APPROVED', 'REJECTED')
and not exists (
select 1
from workflow_decisions d
where d.tenant_id = ts.tenant_id
and d.task_snapshot_id = ts.id
and d.decision_type in ('APPROVE', 'REJECT')
);These queries feed bounded, audited repair workflows, never generic automatic mutation.
API, data, regional, and retention evolution
Evolve HTTP additively and version schemas independently; preserve historical evidence. Route tenant data to its home region and make retention, holds, deletion, backups, and exports policy-driven and auditable.
Concrete implementation sequence
- Write the domain vocabulary, state machine, authority matrix, privacy classification, and downstream boundary. Get accounting, payroll, security, operations, accessibility, and jurisdictional review before encoding policy.
- Implement tenant, principal, membership, document, sealed version, attachment metadata, and audit foundations. Prove cross-tenant denial and restricted projections.
- Implement draft template editing, publication, deterministic matching, and immutable compilation for one document type and sequential steps only.
- Implement inbox, approve, reject, correction, resubmission, history, ETags, and idempotency. Add model, authorization, integration, and end-to-end tests.
- Add outbox and one downstream adapter. Reconcile approval outcomes against published messages and acknowledgements before adding Kafka.
- Add all-of and any-of steps with database locking and deterministic concurrency tests. Add amount conditions only after currency and decimal policy is fixed.
- Add investigation, deadline evaluation, notification digests, reminders, and stale-task cancellation. Operate queue dashboards and runbooks.
- Add date-bounded delegation with server-side eligibility recomputation. Test revocation races, separation rules, and visibility.
- Harden attachment scanning, isolated preview, encryption, key operations, retention, holds, backup, restore, and disaster recovery.
- Add Kafka when replay, independent consumers, or throughput justify it. Establish schema governance, consumer deduplication, gap handling, and per-document ordering.
- Add mobile refinements and carefully bounded bulk actions based on observed workflows and accessibility research.
- Add emergency override only after dedicated authorization, reauthentication, alerts, incident linkage, post-use review, and reporting are operational.
The durable architectural idea is simple: policy may be configurable, but execution evidence must be fixed. A sealed document version, deterministic rule selection, immutable compiled plan, server-recomputed authority, atomic decision transaction, append-only history, and idempotent integration boundary make the system explainable when ordinary paths and failure paths collide.
Sources
- Fortnox support: personnel approval workflows
- Fortnox support: general supplier invoice approval settings
- Fortnox support: getting started with supplier invoice approval
- Fortnox support: creating an approval flow
- Fortnox support: reviewing a supplier invoice
- Spring Framework declarative transaction management
- Spring Security OAuth 2.0 Resource Server JWT
- PostgreSQL UUID type
- PostgreSQL UUID functions
- PostgreSQL SELECT locking clauses
- RFC 9562: Universally Unique IDentifiers
- Apache Kafka 4.3 message delivery semantics
- OpenAPI Specification
- OpenID Connect Core 1.0
- RFC 9457: Problem Details for HTTP APIs
- OWASP API Security Top 10, 2023