Attestation Cycle System¶
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¶
-
No WorkflowInstance for attestations:
submitAttestationResponsedoes not auto-create a workflow instance, unlike Form 3A'ssubmitDeclaration. Attestation declarations bypass the workflow-step approval chain entirely. Compliance approves with a singleapproveAttestationcall. -
Shared formData schema: The
cycleIdis the grouping key.AttestationService.listCycles()groups all attestation declarations for an org byformData.cycleId, then summarises each group. -
CycleId can be pre-supplied: The
cycleIdarg onlaunchAttestationCycleallows callers to pre-select an ID. When omitted, the server generates arandomUUID(). -
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.