Skip to content

Attestation Cycle System

Back to Regnify Product Docs

Previous Next


Overview

Attestation cycles are quarterly compliance attestation events where representatives confirm their CPD standing, CMFAS module status, and absence of adverse changes. Each cycle is implemented as a group of Declaration rows sharing a common cycleId UUID, all with formType = "DECLARATION_ATTESTATION_QUARTERLY".

The attestation system lives in AttestationService (services/nestjs/src/domain/attestation/attestation.service.ts), which wraps the declaration domain to provide cycle grouping, launch, response tracking, and a dedicated approval path. Attestation declarations bypass the normal multi-step workflow chain used by Form 3A -- they are approved by Compliance with a single approveAttestation mutation.

Cycle Lifecycle States

State Description
Scheduled Cycle has been launched (declarations created for all reps), but no rep has submitted yet. All declarations are in Draft status.
Active At least one rep has submitted their response (Draft -> Pending Review). Compliance is reviewing submissions.
Closed All reps have submitted and all responses have been approved (Pending Review -> Approved). completedCount == totalReps.
Overdue Cycle's due date has passed, but not all reps have submitted. Visible as a compliance risk indicator.

The cycle's state is derived, not stored -- it is computed from the aggregate of declaration statuses within the cycle. AttestationService.summarise() computes completedCount (declarations with status Approved, Good, or MAS_APPROVED) and pendingCount (totalReps - completedCount).

Attestation Cycle Data Model

Each attestation declaration stores its cycle metadata in formData, a JSON column on the declarations table:

{
  "cycleId": "<UUID4 string>",
  "orgId": <integer>,
  "templateId": <integer>,
  "launchedAt": "<ISO-8601 string>",
  "launchedByUserId": <integer or null>,
  "responses": {
    "cpdConfirmed": true,
    "cmfasConfirmed": true,
    "noAdverseChange": true,
    "acknowledgedAt": "<ISO-8601 string>"
  }
}

The responses sub-object is populated when the rep calls submitAttestationResponse. Before submission, responses is null.

Creating a Cycle

Via GraphQL (NestJS)

mutation LaunchAttestationCycle($input: LaunchAttestationCycleInput!) {
  launchAttestationCycle(input: $input) {
    cycleId
    launchedAt
    totalReps
    completedCount
    pendingCount
    repProfileIds
  }
}

Input fields (LaunchAttestationCycleInput): - orgId: Int! -- the organisation the cycle belongs to - templateId: Int! -- the attestation workflow template FK - repIds: [Int!]! -- non-empty array of RepProfile.id values to include - launchedByUserId: Int -- optional, the user who launched the cycle - cycleId: String -- optional, a pre-selected cycle ID (defaults to a server-generated randomUUID())

Roles allowed: COMP, HR_ADMIN, ORG_ADMIN, SYS_ADMIN

Server-side behaviour (AttestationService.launchCycle()): 1. Validates repIds is non-empty; throws BadRequestException if empty. 2. Validates the template exists and belongs to the specified org; throws NotFoundException or BadRequestException. 3. Fetches all rep profiles by ID with their linked users; throws NotFoundException if any profile ID is missing. 4. Generates a cycleId UUID if none provided; records launchedAt as current time. 5. For each rep profile, calls declarationService.createByFormType("DECLARATION_ATTESTATION_QUARTERLY", {...}) which creates a Draft declaration with the cycle's shared formData. 6. Returns a cycle summary with cycleId, counts, and the created declaration objects.

Via Chatbot Tool (FastAPI)

Tool name: launch_attestation_cycle

Arguments: - template: TemplateRef -- {"kind": "template", "id": <int>} - reps: list[RepProfileRef] -- non-empty, each {"kind": "rep_profile", "id": <int>} - org: OrgRef | None -- {"kind": "org", "id": <int>}; when omitted, defaults to ctx.org_id

Returns: {"cycle_id", "launched_at", "total_reps", "completed_count", "pending_count", "rep_profile_ids"}. On error, returns {"error": str}.

Rep Submission

submitAttestationResponse

mutation SubmitAttestationResponse($input: SubmitAttestationResponseInput!) {
  submitAttestationResponse(input: $input) {
    id
    status
  }
}

Input fields: - declarationId: Int! - responses: JSONObject! -- typically {cpdConfirmed, cmfasConfirmed, noAdverseChange, acknowledgedAt}

Roles allowed: FI_USER, REP_USER, COMP, HR_ADMIN, ORG_ADMIN, SYS_ADMIN

Server-side behaviour (AttestationService.submitResponse()): 1. Validates the declaration exists and has formType = "DECLARATION_ATTESTATION_QUARTERLY". 2. Validates the declaration status is Draft or Revision Requested; throws BadRequestException otherwise. 3. Merges the responses object into formData under the responses key. 4. Sets submittedAt to current time on both the top-level column and inside formData. 5. Flips status to Pending Review and statusLabel to PENDING_REVIEW.

Critical difference from Form 3A: submitAttestationResponse does NOT auto-create a WorkflowInstance. The DeclarationService.submitDeclaration() path (used by Form 3A) triggers auto-creation of a workflow instance from the org's template. Attestation uses its own submit path (submitResponse) in AttestationService, which skips workflow instance creation entirely. This is the design intent: attestations use single-step Compliance approval, not a multi-step chain.

Compliance Approval

approveAttestation

mutation ApproveAttestation($declarationId: Int!) {
  approveAttestation(declarationId: $declarationId) {
    id
    status
  }
}

Roles allowed: COMP, HR_ADMIN, ORG_ADMIN, SYS_ADMIN

Server-side behaviour (AttestationService.approveAttestation()): 1. Validates the declaration exists and has formType = "DECLARATION_ATTESTATION_QUARTERLY"; throws BadRequestException if not. 2. Delegates to declarationService.approveDeclaration(declarationId), which validates the status transition Pending Review -> Approved and sets status = "Approved", statusLabel = "APPROVED", approvedAt = now().

This is a thin wrapper. The formType guard prevents misuse: attempting to approve a Form 3A through approveAttestation throws a BadRequestException with a clear message.

Anomaly Detection

detect_attestation_anomaly (Chatbot Tool)

Arguments: - cycle_id: str -- string UUID or integer (as returned by launch_attestation_cycle) - rep: RepProfileRef -- {"kind": "rep_profile", "id": <int>}

Behaviour: Gathers three data slices for LLM analysis: 1. attestationResponses(cycleId) -- all attestation declarations in the target cycle, filtered client-side to the rep. 2. declarations(where: {repProfileId: $repId}) -- the rep's historical Form 3A declarations. 3. auditLogs(where: {userId: $userId}, take: 50) -- recent audit entries for the rep's linked user.

The LLM returns structured JSON: {has_anomaly: bool, severity: "low"|"medium"|"high", fields_flagged: [str], reason: str}. If no responses are found for the rep in the cycle, returns {"error": str}.

This tool serves DUAL purpose: it is the correct tool for BOTH anomaly detection AND "has rep X completed cycle Y?" status checks. Zero responses = non-completion.

GraphQL Queries for Cycle Data

Query Returns Roles
attestationCycle(cycleId: String!) Single AttestationCycleModel summary COMP, HR_ADMIN, ORG_ADMIN, SYS_ADMIN
attestationCycles(orgId: Int!, limit: Int) Array of AttestationCycleModel, most recent first, max limit (default 20) COMP, HR_ADMIN, ORG_ADMIN, SYS_ADMIN
attestationResponses(cycleId: String!) Array of DeclarationModel for all declarations in the cycle COMP, HR_ADMIN, ORG_ADMIN, SYS_ADMIN

Frontend Pages

  • Admin Attestation Page: /admin/attestation -- shows all cycles for the admin's org with completion statistics (total reps, submitted count, approved count).
  • Rep Attestation Page: /rep/attestation -- shows pending attestation cycles for the currently logged-in representative.

Seed Data (Demo Environment)

All seeded via production GraphQL mutations (idempotent -- deletes all existing attestation declarations at start):

Cycle Mode Behaviour
Q1 2026 Closed All reps submitted + approved (full lifecycle demonstration). Reps submit {cpdConfirmed: true, cmfasConfirmed: true, noAdverseChange: true}; admin then calls approveAttestation per declaration.
Q2 2026 Active Launched for all 5 FIs, no submissions yet -- simulates a just-opened quarterly cycle.
Spot Check May 2026 Overdue Launched for first 2 FIs (DBS, UOB) only. Half the reps submitted (with acknowledgedAt set 10 days in the past to simulate overdue). Other half remain in Draft.
Q3 2026 Scheduled Launched for all 5 FIs, 0 submissions -- simulates future quarter.
Q4 2026 Scheduled Launched for all 5 FIs, 0 submissions -- simulates future quarter.

The overdue mode is a compute: the cycle is overdue because (a) the due date has passed and (b) < totalReps have submitted. The seed sets acknowledgedAt 10 days ago on partial submissions to simulate this.

Key Architectural Decisions

  1. No WorkflowInstance for attestations: submitAttestationResponse does not auto-create a workflow instance, unlike Form 3A's submitDeclaration. Attestation declarations bypass the workflow-step approval chain entirely. Compliance approves with a single approveAttestation call.

  2. Shared formData schema: The cycleId is the grouping key. AttestationService.listCycles() groups all attestation declarations for an org by formData.cycleId, then summarises each group.

  3. CycleId can be pre-supplied: The cycleId arg on launchAttestationCycle allows callers to pre-select an ID. When omitted, the server generates a randomUUID().

  4. Idempotency at seed time only: The seed function deletes all existing attestation declarations before re-creating. This is NOT a runtime pattern -- it exists only for seed data reproducibility.


Previous Next

Back to Regnify Product Docs