Content Approval Workflow Low-Level Design: Multi-Stage Review, Gating Rules, and Audit Trail

A content approval workflow enforces that content passes through the right human reviewers before publication. The workflow must be configurable (different content types need different review stages), enforced by role-based gating rules, and fully auditable. This post covers the design of a flexible, compliant approval workflow system.

Requirements

Functional Requirements

  • Define configurable multi-stage review workflows per content type
  • Each stage has a set of required approver roles and a minimum approval count
  • Reviewers can approve, reject, or request changes at any stage
  • Rejection at any stage routes content back to draft with a comment
  • All approval and rejection actions are recorded in an immutable audit trail
  • Notify assigned reviewers on stage entry via email or webhook

Non-Functional Requirements

  • Audit trail entries must be tamper-evident
  • Workflow configuration changes must not affect in-flight submissions
  • Support up to 20 workflow stages and 100 concurrent submissions per workflow

Data Model

The workflow_definitions table stores the template: workflow_id, name, content_type, version (for immutable snapshots), created_at, is_active. Each workflow has a set of workflow_stages rows: stage_id, workflow_id, workflow_version, stage_order, stage_name, required_role, min_approvals, timeout_hours (nullable).

Workflow definitions are versioned. When a definition changes, a new version is created. In-flight submissions retain a reference to the workflow_version that was active when the submission was created. This ensures a mid-flight workflow change does not alter the rules for submissions already in progress.

The submissions table tracks each piece of content moving through a workflow: submission_id, content_id, workflow_id, workflow_version, current_stage_id, status (pending/approved/rejected/withdrawn), submitted_by, submitted_at.

The review_actions table is the audit log: action_id, submission_id, stage_id, reviewer_id, action (approved/rejected/changes_requested/reassigned), comment, created_at, previous_stage_id. This table is insert-only. No row is ever updated or deleted. To make entries tamper-evident, each row includes a sha256 hash chained from the previous row: chain_hash = SHA256(previous_chain_hash || action_id || reviewer_id || action || created_at).

Core Algorithms

Stage Gating

When a review action is submitted, the service first checks that the reviewer holds the required_role for the current stage. If not, the action is rejected with a 403. Next, it counts how many approvals have been recorded for the current stage from distinct reviewers with the required role. If the count meets or exceeds min_approvals, the stage is complete. The submission advances to the next stage (updating current_stage_id) and notifications are sent to reviewers for the new stage.

Rejection and Routing

A rejection at any stage transitions the submission status to rejected and records the action in the audit log. The system notifies the original submitter with the rejection comment. The submitter can address the feedback and resubmit, which creates a new submission record linked to the content_id (the old submission is not modified). This creates a clean trail of submission attempts.

Timeout Handling

If a stage has a timeout_hours value, a background job checks submissions where the current stage entry time exceeds the timeout. On timeout, the configured action fires (escalate to a manager role, auto-approve, or auto-reject) based on the timeout_action field on the stage definition. The action is logged in review_actions like any other action.

API Design

  • POST /workflows — Create or update a workflow definition (creates a new version)
  • GET /workflows/{workflow_id} — Return the current active workflow definition with stages
  • POST /submissions — Submit a content_id for review; assigns workflow by content_type
  • GET /submissions/{submission_id} — Return submission status and current stage
  • POST /submissions/{submission_id}/actions — Record a reviewer action (approve/reject/request_changes)
  • GET /submissions/{submission_id}/audit — Return the full audit trail with chain hashes for verification
  • GET /queue — Return submissions awaiting the authenticated user based on their roles

Scalability and Notifications

Workflow state transitions are low-frequency relative to content read traffic. The submissions and review_actions tables are modestly sized. A standard relational database (PostgreSQL) handles the workload without sharding for most organizations.

Notifications are dispatched asynchronously via an event bus (Kafka). The workflow service publishes a stage_entered event; a notification worker subscribes and sends emails or webhooks. This decouples notification delivery from the approval state machine. Failed notifications are retried with exponential backoff without blocking the approval flow.

The reviewer queue endpoint must return quickly for dashboard use. It is powered by a query on submissions joined to workflow_stages on current_stage_id, filtered by the reviewer role. An index on (current_stage_id, status) keeps this query fast even with many in-flight submissions.

Interview Talking Points

The key design decisions are: workflow versioning to protect in-flight submissions from definition changes, the chained hash audit trail for tamper-evidence, and the separation of the gating logic (role check + approval count) from notification side effects. Be ready to explain why rejection creates a new submission rather than reverting the old one, and how the timeout handler is implemented without requiring application-level cron precision — a polling job with a short interval is sufficient and simpler than complex scheduling infrastructure.

{
“@context”: “https://schema.org”,
“@type”: “FAQPage”,
“mainEntity”: [
{
“@type”: “Question”,
“name”: “How do you design a configurable multi-stage content approval workflow?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Model the workflow as an ordered list of stages stored in a workflow_template table (stage_id, order, name, required_approvers, approval_strategy). Each piece of content gets a workflow_instance row referencing the template. As approvals are collected per stage, a service checks the strategy (ANY for any one approver, ALL for unanimous, MAJORITY for quorum) and advances the instance to the next stage or marks it approved. Templates are versioned so in-flight instances are unaffected by template edits.”
}
},
{
“@type”: “Question”,
“name”: “How do you implement role-based gating rules in an approval workflow?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Each stage specifies one or more required_roles. When evaluating whether a user can approve a stage, check that the user holds at least one of those roles (via RBAC lookup) and has not already approved the same stage (self-approval prevention). Store role assignments in a separate roles table joined at evaluation time rather than denormalizing roles onto the approval record, so role changes take effect immediately for pending stages.”
}
},
{
“@type”: “Question”,
“name”: “How do you implement comment threading on a content approval workflow?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Store comments in a comments table with (id, content_id, parent_comment_id, author_id, body, created_at). A NULL parent_comment_id denotes a top-level comment; a non-NULL value creates a reply thread. Fetch threads with a recursive CTE or by loading all comments for the content and assembling the tree in application memory. Associate each comment with the content's version at the time of posting so reviewers can see what version was being discussed.”
}
},
{
“@type”: “Question”,
“name”: “How do you build an immutable audit trail for a content approval workflow?”,
“acceptedAnswer”: {
“@type”: “Answer”,
“text”: “Write every workflow event (stage transition, approval, rejection, comment, role-gate override) as an INSERT-only row in an audit_log table with (event_id, content_id, workflow_instance_id, actor_id, event_type, payload_json, created_at). Never UPDATE or DELETE audit rows — grant only INSERT on this table to the application role. For tamper evidence, chain a SHA-256 hash of (previous_hash + event payload) on each row, making any retroactive modification detectable.”
}
}
]
}

See also: Netflix Interview Guide 2026: Streaming Architecture, Recommendation Systems, and Engineering Excellence

See also: Atlassian Interview Guide

See also: Scale AI Interview Guide 2026: Data Infrastructure, RLHF Pipelines, and ML Engineering

Scroll to Top