S
supero.docs
Documentation/Application Architectures/Approvals & E-Signature
Solution Architecture

Approvals & E-Signature

Multi-step approval chains, signed documents, and the audit trail around them — built on the approval and document_signature base types, from a real 22-schema clinical trial application.

An approval header with ordered steps, alongside a document and its signatures
Publish this elsewhere:

The Project

Some applications exist because something must be reviewed before it counts. A protocol amendment needs sign-off from three people in order. A waiver must be signed before anyone sets foot on the floor. A supplier change needs a paper trail that survives an audit. The record is not the point — the sequence of decisions around it is.
The exemplar is app_ctr in ~/supero-test/e2e-tests/apps/app_ctr, namespace trialhub — a clinical trial application with 22 schemas. Its header declares SUPERO_APP_ARCHETYPE: saas and its services: task, approval, notification, workflows, email_smtp, appointment, attachment.

ℹ️ Two exemplars, because no one bundle uses both halves

The approval half comes from app_ctr, which uses approval:base_approval and no e-signature at all. The signature half comes from app_m71-gym3, whose waiver and waiver_signature extend document_signature:base_document and :base_signature. Both are real; they are simply not in the same bundle.

Architecture & Design

Two pairs carry this architecture: an approval and its ordered steps, and a document and its signatures.
An approval is two types, not one: a header carrying the decision, and a step carrying each reviewer’s turn. The step is parented to the header, which is what makes the chain a chain rather than a bag of records.
python
{"name": "TrialApproval",
 "namespace": "trialhub",
 "schema_type": "object",
 "parent_type": "tenant",
 "extends": "approval:base_approval",
 "attributes": [
     {"name": "subject", "type": "string", "mandatory": True},
     {"name": "approval_type", "type": "string"},
     {"name": "requested_at", "type": "string"}],
 "references": [
     {"name": "Trial", "cardinality": "one",
      "back_ref_name": "approvals"}]}

{"name": "TrialApprovalStep",
 "namespace": "trialhub",
 "schema_type": "object",
 "parent_type": "trial_approval",
 "extends": "approval:base_approval_step",
 "attributes": [
     {"name": "approval_uuid", "type": "string", "mandatory": True},
     {"name": "step_index", "type": "integer", "mandatory": True},
     {"name": "step_notes", "type": "text"}]}
Note parent_type: "trial_approval" on the step. Parenting is how ownership and addressing work — a step is not a free-floating record holding a foreign key.
Your typeExtendsRole in the architecture
TrialApprovalapproval:base_approvalThe decision itself. subject is mandatory; approval_type and requested_at are the app’s own.
TrialApprovalStepapproval:base_approval_stepOne reviewer’s turn, ordered by step_index, parented to the approval.
TrialDocumentattachment:base_attachmentConsent forms and lab reports, referencing their Participant.
TrialTasktask:base_taskOperational follow-up, attached to a Trial.
TrialNotificationnotification:base_notificationStaff alerts, with notification_category and is_read.
Visitappointment:base_appointmentA scheduled visit, with actor and resource reference roles.
waiver *(gym exemplar)*document_signature:base_documentstatus (draft, awaiting_signatures, completed, voided, declined), required_signatures, completed_signatures, expires_at, checksum.
waiver_signature *(gym exemplar)*document_signature:base_signaturestatus (pending, signed, declined), signer_uuid, signer_email, signed_at, signature_data, ip_address.
The rest of app_ctr extends nothing: Organization, Trial, Site, Participant, AdverseEvent, plus ten enums including PhaseEnum, ParticipantStatusEnum, SeverityEnum, SeriousnessEnum and OutcomeEnum. Enumerating your vocabulary that precisely is most of what makes a regulated application reviewable.

⚠️ extends belongs inside schema_content

At the wrapper level on upload it is silently stripped: the schema saves without error and you get a plain object type with no approval behaviour and no state machine. The field list key is attributes, entries {name, type, mandatory} — not JSON Schema.

The Flow

  1. 1

    Something needs a decision

    A TrialApproval is created with a subject, an approval_type and a reference to the Trial it concerns.

  2. 2

    The chain is laid out

    One TrialApprovalStep per reviewer, parented to the approval and ordered by step_index.

  3. 3

    Reviewers act

    Each decision goes through POST /api/v1/services/execute with service_id: "approval". There is no approval API of its own — one endpoint serves every service.

  4. 4

    Illegal moves are refused

    Sixteen services ship state machines. Approving an already-decided step, or reopening a closed approval, returns 409 INVALID_STATE_TRANSITION rather than a nonsense status.

  5. 5

    A document gets signed

    A document sits at awaiting_signatures, counting completed_signatures against required_signatures until it reaches completed.

  6. 6

    Something material happens

    An event binding fires a workflow. In app_ctr it is on adverse events — exactly the class of thing you never want to depend on a human remembering.

json
// app_ctr/event_bindings.json
[{"event": "@create:trialhub:adverse_event",
  "workflow_id": "adverse_event_alert",
  "input_map": {"event_uuid": "uuid",
                "event_name": "event_name",
                "severity": "severity"}}]
The syntax is @<verb>:<namespace>:<schema>, and input_map maps fields on the created record to workflow inputs. The workflow is a CRUD record of type wf:workflow_definition, each run a workflow_instance and each step a workflow_step_log.

What You Get Without Building It

  • •The header-and-step structure, with the step parented to the approval so the chain is addressable rather than assembled by query.
  • •State machines that refuse illegal transitions with 409 INVALID_STATE_TRANSITION instead of corrupting a status field.
  • •Signature counting: required_signatures, completed_signatures, and a status that reaches completed on its own terms.
  • •Per-step logging — workflow_instance and workflow_step_log are ordinary records you can query, not engine internals.
  • •Event bindings, so an approval or an incident triggers a workflow at creation without polling.
  • •The platform roles, with fq_name — [domain, project, tenant, record] — as the record’s immutable address.
  • •Selective publicity: app_ctr declares PUBLIC_SCHEMAS = ["trial", "site", "landing_override"] — trials and sites public, participants and adverse events not.

💡 Field-level protection

Regulated applications usually have a few fields needing more than role-level protection — an identifier, a contact detail, a clinical note. Read Field Encryption before you model those, not after.

How to Implement in Supero

Two kinds of work. First you model the shape above and point each type at its base type; then you write the routing and policy the base types deliberately leave to you.

Model it

  1. 1

    Build the schema

    Describe the app in App Studio — it generates the schemas and a reviewable UI — or author it over MCP from your editor. Aim at the object model above: a type opts into a service by extending its base type. Schemas are live on the CRUD API the moment they upload — there is no separate build step to run.

  2. 2

    Configure the services

    Imported services arrive uncredentialed. Put the keys for email or SMS notifications in the keystore so approval routing can reach reviewers — the document_signature service itself needs no external key. If the data must stay inside your own infrastructure, this is also the shape you deploy to your own cloud or on-premise — see Deployment.

What you write yourself

⚠️ The routing rules are yours, and so is what a signature means

The platform gives you an ordered chain and a state machine over it. It does not decide who is in the chain, whether two of three approvers is enough, what happens when a reviewer is on leave, or whether a rejection at step two cancels steps three and four. On the signature side, signature_data and ip_address are fields the base type provides for you to populate — what you write into them, and what legal weight it carries in your jurisdiction, is your determination. The presence of these fields is not a compliance claim by the platform.
DecisionWhy it is yours
Quorum and delegationSteps are ordered. Whether three of five suffices, and who may act for whom, is logic you write in the workflow.
Escalation on a stalled stepWorkflows offer on_error of stop, continue, retry or compensate, with max_retries and timeout_seconds. The policy is your design.
Immutability after approvalThe platform does not freeze a record because an approval closed. If an approved protocol must not change, enforce it.
Identity of the signerThe base type records signer_uuid and signer_email. Verifying the human is your process.
Retention, and what the trail containsYou get a per-step log. How long it is kept, and whether its fields satisfy your regulator, are not platform settings.
One practical note: app_ctr imports email_smtp rather than SendGrid — the usual choice when notifications must leave through infrastructure you control. Both configure the same way, with credentials in the keystore.

Deployment

The path from this shape to a running approvals application at a public URL, end to end:
  1. 1

    Preview while you iterate

    A preview deploy answers at https://preview-{domain}-{project}.supero.live for a short lease while you build and test. Publish Your App covers how preview and live differ.

  2. 2

    Go Live

    Go Live publishes to a permanent, Supero-hosted URL — https://live-{domain}-{project}.supero.live — with no cloud account to connect and nothing to provision. It is asynchronous: you get a 202 and a poll URL, then the live address. The full contract is in Publish Your App.

  3. 3

    Watch the logs

    When a deploy fails or the live app misbehaves, the **Cloud Deploy Logs** page in the dashboard shows the published service’s container logs — pick the Live lane to read the published app rather than the preview build (up to ~24 hours back, ~500 lines per request). For platform activity — requests, auth, errors across your domain — GET /api/v1/logs is a filtered read, and a single request traces across services at /api/v1/logs/trace/{correlation_id} when you set your own X-Correlation-ID. See Operations & Observability.

Final Thoughts

This shape fits work where the sequence of decisions around a record matters more than the record itself — an approval chain, a signed document, and the trail they leave. Where there is no ordered decision to capture, a plain object type that extends nothing is the simpler choice; the base types earn their place only when a state machine does.
If you carry one gotcha into the build, carry this: extends must sit inside schema_content. At the wrapper level it is silently stripped on upload — the schema saves without error, and you are left with a plain object type that has no approval behaviour and no state machine.
What you own is the routing, the quorum rule, the escalation policy, and what a signature means in your jurisdiction — the platform decides none of these for you. The ordered chain, the state machines, and the base types underneath them stay the platform's; you write the policy over the machinery, not the machinery itself.