Skip to content

Indicators Quickstart

Last Updated: 2026-06-08 API Version: v2.0 Base URL: http://localhost:10001 (development)

This guide walks through creating project-scoped indicators, applying them to controls, and sending events for evaluation.


  1. Overview
  2. Prerequisites
  3. Core Concepts
  4. Creating Indicators
  5. Applying Indicators to Controls
  6. Sending Events
  7. Viewing Results
  8. Complete Example
  9. Troubleshooting

The Indicators system provides a unified framework for compliance monitoring through custom indicators: event-driven evaluations that extract fields from incoming events using JSONPath and check them against criteria.

An indicator is a project-owned template that defines both what to monitor and how to evaluate it. Applying that indicator to one or more controls creates lightweight applications that link the template to specific controls. Events are then routed by projectId + indicatorId and fanned out to every active application of that indicator.

┌─────────────┐ ┌──────────────┐ ┌─────────────┐ ┌──────────────┐
│ Create │ --> │ Apply to │ --> │ Send Events │ --> │ Results │
│ Indicator │ │ Control(s) │ │ for Eval │ │ Declarations │
└─────────────┘ └──────────────┘ └─────────────┘ └──────────────┘
(Template w/ (Status-only (Ingestion, (Evidence)
extraction + application per fans out to all
criteria + control) active apps)
declaration)

  1. Project & Control Context: You need:

    • A project ID (and its project UUID for event ingestion)
    • A policy attached to the project
    • A control within the policy
    • A project_policy_control ID (the junction record linking a project policy to a control) for each control you apply an indicator to
  2. Authentication:

    • JWT Bearer token with project access and the appropriate permissions (creating, applying, activating, disabling, and archiving indicators are each gated by their own permission; reads require project-data access)
    • Or an API key for service-to-service calls
  3. Database:

    • Governance service running locally or in your environment
    • All database migrations applied (the latest relevant migration, 115_rename_configuration_to_application, finalizes the configuration → application rename)
-- Find your project_policy_control_id
SELECT ppc.id AS project_policy_control_id,
p.name AS project_name,
pp.name AS policy_name,
c.title AS control_title
FROM project_policy_control ppc
JOIN project_policy pp ON ppc.project_policy_id = pp.id
JOIN project p ON pp.project_id = p.id
JOIN control c ON ppc.control_id = c.id
WHERE p.id = <YOUR_PROJECT_ID>
AND pp.policy_id = <YOUR_POLICY_ID>
AND c.id = <YOUR_CONTROL_ID>;

Project-scoped indicator and application routes use the numeric project ID in the path (/api/v2/projects/{projectId}/...). Event ingestion uses the project UUID in the projectId body field.


Indicators are immutable templates owned by a project. A single indicator carries everything needed to evaluate an event:

  • Identity: name, slug, description, type (custom), tags, provider
  • triggerType (event)
  • extractionRules: JSONPath expressions that pull fields out of an event
  • criteria: the conditions that determine pass/fail (all must pass)
  • declarationProfile: when and how to create declarations

Key Point: In v2 the evaluation behavior lives on the indicator template, not on a separate application object.

Section titled “2. Applications (Status-Only Links to Controls)”

An application links an indicator to a single project_policy_control. It is status-only — it carries an active / disabled / archived status and inherits all extraction, criteria, and declaration behavior from its indicator template at evaluation time.

Key Point: One indicator can be applied to many controls. Each application is an independent on/off switch for that indicator on a specific control.

When you send an indicator event:

  1. The service resolves the project UUID and looks up the indicator
  2. It fans out to all active applications of that indicator in the project
  3. For each application, the processor extracts fields and evaluates criteria
  4. Results are stored as evaluation results
  5. Declarations are created based on the indicator’s declaration profile
  6. Evidence is preserved for the audit trail

POST /api/v2/projects/{projectId}/indicators
Content-Type: application/json
Authorization: Bearer <token>

Use Case: Monitor S3 bucket encryption

{
"type": "custom",
"name": "S3 Bucket Encryption Status",
"description": "Monitors whether S3 buckets have encryption enabled",
"tags": ["aws", "s3", "encryption", "storage"],
"provider": "aws",
"triggerType": "event",
"extractionRules": {
"bucketName": "$.detail.bucket.name",
"encryptionEnabled": "$.detail.bucket.encryption.enabled",
"encryptionType": "$.detail.bucket.encryption.type",
"region": "$.detail.bucket.region"
},
"criteria": [
{
"type": "boolean",
"field": "encryptionEnabled",
"operator": "eq",
"criteriaValue": true
},
{
"type": "string",
"field": "encryptionType",
"operator": "in",
"criteriaValue": ["AES256", "aws:kms"]
}
],
"declarationProfile": {
"emitOn": "fail",
"titleTemplate": "S3 Bucket {{bucketName}} Missing Encryption",
"statementTemplate": "The S3 bucket {{bucketName}} in region {{region}} does not have encryption enabled. All buckets must use AES256 or KMS encryption."
}
}

Field Breakdown:

FieldRequiredDescription
typeYesMust be "custom" (defaults to custom if omitted)
nameYesHuman-readable indicator name
slugNoStable identifier; generated from name if omitted
descriptionNoFree-text description
tagsNoTags for discovery/filtering
providerNoProvider hint (e.g., "aws")
triggerTypeYesMust be "event" (defaults to event if omitted)
extractionRulesYesJSONPath expressions to extract fields from the event
criteriaYesConditions to evaluate (all must pass)
declarationProfileYesWhen and how to create declarations (emitOn is required)

Response (201 Created):

{
"id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"projectId": 456,
"name": "S3 Bucket Encryption Status",
"slug": "s3-bucket-encryption-status",
"description": "Monitors whether S3 buckets have encryption enabled",
"type": "custom",
"tags": ["aws", "s3", "encryption", "storage"],
"provider": "aws",
"triggerType": "event",
"extractionRules": {
/* as provided */
},
"criteria": [
/* as provided */
],
"declarationProfile": {
/* as provided */
},
"createdAt": "2026-06-08T10:00:00Z",
"updatedAt": "2026-06-08T10:00:00Z",
"usageCount": 0
}
Terminal window
GET /api/v2/projects/{projectId}/indicators?search=encryption&tag=aws&limit=50&offset=0

Supported query parameters: search (matches name/description), tag, limit (1–500, default 50), offset.

Response:

{
"indicators": [
{
/* project indicator object, including usageCount */
}
],
"total": 15,
"limit": 50,
"offset": 0
}
Terminal window
# Get one indicator (includes usageCount)
GET /api/v2/projects/{projectId}/indicators/{indicatorId}
# Archive an indicator (only allowed when it has no non-archived applications)
DELETE /api/v2/projects/{projectId}/indicators/{indicatorId}

Archiving returns 204 No Content. If the indicator still has active applications, the request returns 409 Conflict.


Applying an indicator creates one status-only application per target control.

POST /api/v2/projects/{projectId}/indicators/{indicatorId}/applications
Content-Type: application/json
Authorization: Bearer <token>
{
"projectPolicyControlIds": [12345, 12346],
"status": "active"
}
FieldRequiredDescription
projectPolicyControlIdOne of*Single project_policy_control ID
projectPolicyControlIdsOne of*List of project_policy_control IDs to apply in one call
statusNoactive (default) or disabled

*Provide projectPolicyControlId, projectPolicyControlIds, or both; all referenced controls must belong to the project.

Response (201 Created):

{
"applications": [
{
"id": 789,
"applicationUuid": "f6a7b8c9-d0e1-2345-f012-456789012345",
"indicatorId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"projectPolicyControlId": 12345,
"status": "active",
"createdAt": "2026-06-08T10:15:00Z",
"updatedAt": "2026-06-08T10:15:00Z"
}
]
}
Terminal window
# List applications for a single indicator
GET /api/v2/projects/{projectId}/indicators/{indicatorId}/applications
# List enriched applications across the project (template + policy/control context +
# latest evaluation). Filters: indicatorId, projectPolicyId, projectPolicyControlId,
# applicationUuid, status, includeArchived, limit, offset.
GET /api/v2/projects/{projectId}/indicator-applications
# Get one enriched application by numeric ID or application UUID
GET /api/v2/projects/{projectId}/indicator-applications/{applicationId}
# Activate an application
POST /api/v2/projects/{projectId}/indicator-applications/{applicationId}/enable
# Disable an application (stops it from being evaluated)
POST /api/v2/projects/{projectId}/indicator-applications/{applicationId}/disable
# Remove (archive) an application
DELETE /api/v2/projects/{projectId}/indicator-applications/{applicationId}

enable and disable return the updated application; delete archives the application and returns 204 No Content.


POST /api/v2/events/ingest
Content-Type: application/json
Authorization: Bearer <token>

The endpoint accepts the event and returns 202 Accepted; evaluation happens against every active application of the indicator.

Example: S3 bucket state change event

{
"eventType": "indicator",
"eventSubtype": "resource_state",
"source": "aws",
"timestamp": "2026-06-08T10:30:00Z",
"projectId": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"indicatorId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"provider": "aws",
"service": "s3",
"payload": {
"version": "0",
"id": "12345678-1234-1234-1234-123456789012",
"detail-type": "AWS API Call via CloudTrail",
"source": "aws.s3",
"account": "123456789012",
"time": "2026-06-08T10:30:00Z",
"region": "us-east-1",
"detail": {
"bucket": {
"name": "my-production-bucket",
"encryption": {
"enabled": false
},
"region": "us-east-1"
}
}
}
}

Request Fields:

FieldRequiredDescription
eventTypeYesMust be "indicator"
eventSubtypeNoEvent category (e.g., "resource_state")
sourceYesEvent source (e.g., "aws", "external")
timestampYesISO 8601 timestamp
projectIdYesProject UUID (resolved server-side, access checked)
indicatorIdYesIndicator UUID to route the event to
providerNoProvider hint (e.g., "aws")
serviceNoService hint (e.g., "s3")
payloadYesEvent data (any JSON structure)
correlationIdNoFor event tracing
metadataNoAdditional metadata

Removed: controlId is no longer accepted for routing — projectId + indicatorId fans out to all active applications of the indicator. Sending controlId returns 400 Bad Request. There is also no policy event type or indicatorName routing key in v2.

Routing & Processing:

  1. The project UUID is resolved to a project and access is verified
  2. The indicator is looked up and confirmed to be an active, project-scoped indicator
  3. The event fans out to all active applications of that indicator
  4. For each application, the processor extracts fields using extractionRules, evaluates criteria, stores an evaluation result, and creates a declaration when the declarationProfile dictates
{
"eventId": "12345678-1234-1234-1234-123456789012",
"status": "processed",
"evaluationsCreated": 1,
"declarationsCreated": [567]
}

Status Values:

  • processed: Event matched one or more active applications and was evaluated
  • no_route: No active application matched (unknown/archived indicator, or none applied)

If individual applications fail to process, the response includes an errors array while still reporting the evaluations and declarations that succeeded.


Terminal window
# List evaluation results (filters below)
GET /api/v2/evaluations?applicationId=789&result=fail&limit=50&offset=0
# Get a single evaluation result by UUID
GET /api/v2/evaluations/{id}

Supported list filters: applicationId, indicatorId, projectId, projectPolicyId, projectPolicyControlId, result (pass, fail, error, pending), since/until (ISO 8601), limit (1–200, default 50), offset.

Response:

{
"results": [
{
"id": "eval1234-1234-1234-1234-123456789012",
"applicationId": 789,
"indicatorId": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
"result": "fail",
"extractedFields": {
"bucketName": "my-production-bucket",
"encryptionEnabled": false,
"region": "us-east-1"
},
"failureReasons": ["Criteria failed: encryptionEnabled must be true"],
"payloadTruncated": false,
"evaluatedAt": "2026-06-08T10:30:05Z",
"durationMs": 45,
"declarationIds": [567],
"createdAt": "2026-06-08T10:30:05Z"
}
],
"total": 1,
"limit": 50,
"offset": 0
}

Declarations are created in the existing declaration table:

Terminal window
# Get declarations for a control
GET /api/v1/projects/{projectId}/policies/{projectPolicyId}/controls/{projectPolicyControlId}/declarations

Declaration Fields:

  • title: Generated from titleTemplate with extracted fields
  • statement: Generated from statementTemplate with extracted fields
  • evidence: Link to the evaluation result and raw event payload

Terminal window
curl -X POST http://localhost:10001/api/v2/projects/456/indicators \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"type": "custom",
"name": "S3 Bucket Encryption",
"description": "Ensures S3 buckets have encryption enabled",
"tags": ["aws", "s3", "encryption"],
"provider": "aws",
"triggerType": "event",
"extractionRules": {
"bucketName": "$.detail.bucket.name",
"encryptionEnabled": "$.detail.bucket.encryption.enabled"
},
"criteria": [
{
"type": "boolean",
"field": "encryptionEnabled",
"operator": "eq",
"criteriaValue": true
}
],
"declarationProfile": {
"emitOn": "fail",
"titleTemplate": "Bucket {{bucketName}} lacks encryption",
"statementTemplate": "S3 bucket {{bucketName}} must have encryption enabled"
}
}'

Save the id from the response (e.g., 550e8400-e29b-41d4-a716-446655440000).

Terminal window
curl -X POST http://localhost:10001/api/v2/projects/456/indicators/550e8400-e29b-41d4-a716-446655440000/applications \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"projectPolicyControlIds": [12345],
"status": "active"
}'
Terminal window
curl -X POST http://localhost:10001/api/v2/events/ingest \
-H "Content-Type: application/json" \
-H "Authorization: Bearer $TOKEN" \
-d '{
"eventType": "indicator",
"eventSubtype": "resource_state",
"source": "aws",
"timestamp": "2026-06-08T10:30:00Z",
"projectId": "9f8e7d6c-5b4a-3210-fedc-ba9876543210",
"indicatorId": "550e8400-e29b-41d4-a716-446655440000",
"provider": "aws",
"service": "s3",
"payload": {
"detail": {
"bucket": {
"name": "my-test-bucket",
"encryption": {
"enabled": false
}
}
}
}
}'
Terminal window
# Evaluation results for the application
curl "http://localhost:10001/api/v2/evaluations?indicatorId=550e8400-e29b-41d4-a716-446655440000" \
-H "Authorization: Bearer $TOKEN"
# Declarations on the control
curl "http://localhost:10001/api/v1/projects/456/policies/$PROJECT_POLICY_ID/controls/$PROJECT_POLICY_CONTROL_ID/declarations" \
-H "Authorization: Bearer $TOKEN"

Symptom: Event returns no_route status

Causes:

  1. The indicator UUID does not exist, is archived, or is not a project-scoped indicator
  2. No active application exists for the indicator (it was never applied, or all applications are disabled/archived)
  3. Wrong eventType (must be "indicator")

Solutions:

Terminal window
# 1. Confirm the indicator exists and is active
GET /api/v2/projects/{projectId}/indicators/{indicatorId}
# 2. Confirm active applications exist for the indicator
GET /api/v2/projects/{projectId}/indicators/{indicatorId}/applications
# 3. Enable a disabled application
POST /api/v2/projects/{projectId}/indicator-applications/{applicationId}/enable

Symptom: Event returns 400 Invalid projectId or 403 Forbidden

Causes:

  1. projectId in the event body is not a valid project UUID
  2. The authenticated user/token does not have access to the resolved project

Solution: Use the project UUID (not the numeric ID) in the event projectId field, and ensure the token has project access.

Symptom: Event returns 400 with “controlId routing is no longer supported”

Cause: v2 routes by projectId + indicatorId and fans out to all active applications. Remove controlId from the event body. To scope evaluation to specific controls, control which applications are active.

Symptom: All evaluations show result: "fail"

Causes:

  1. JSONPath extraction rules don’t match the event structure
  2. Criteria types don’t match extracted field types
  3. Criteria operators incorrect for the data type

Solutions:

Terminal window
# Test JSONPath expressions online: https://jsonpath.com
# Inspect extractedFields and failureReasons on the evaluation result
GET /api/v2/evaluations?indicatorId=<UUID>

Symptom: Evaluation succeeds but no declaration appears

Cause: The indicator’s declarationProfile.emitOn did not match the result:

  • emitOn: "fail" only creates on failures
  • emitOn: "pass" only creates on passes
  • emitOn: "both" creates on either
  • emitOn: "none" never creates

Solution: Check the indicator template’s declaration profile and confirm the evaluation result matches the configured emit mode.

Symptom: Don’t know what value to use for projectPolicyControlId

Solution:

SELECT ppc.id, p.name, pp.name, c.title
FROM project_policy_control ppc
JOIN project_policy pp ON ppc.project_policy_id = pp.id
JOIN project p ON pp.project_id = p.id
JOIN control c ON ppc.control_id = c.id
WHERE p.id = <YOUR_PROJECT_ID>
AND c.id = <YOUR_CONTROL_ID>;

Or use the API:

Terminal window
GET /api/v1/projects/{projectId}/policies/{projectPolicyId}/details
# Find the control you want and use its projectPolicyControlId

Symptom: Error “invalid JSONPath expression”

Common Issues:

  • Missing $ prefix: Use $.detail.bucket.name not detail.bucket.name
  • Array access: Use $.items[0].name or $.items[*].name
  • Deep nesting: Each level needs a dot: $.a.b.c.d

Tool: Test at https://jsonpath.com

Symptom: Evaluation fails with “Criteria failed” but values look correct

Type Mismatches:

// Wrong: string criteria for a boolean field
{
"type": "string",
"field": "encryptionEnabled",
"operator": "eq",
"criteriaValue": "true" // ❌ String
}
// Correct: boolean criteria
{
"type": "boolean",
"field": "encryptionEnabled",
"operator": "eq",
"criteriaValue": true // ✅ Boolean
}

Operator Compatibility:

TypeValid Operators
stringeq, neq, in, notIn, contains, notContains
numbereq, neq, gt, lt, gte, lte, in, notIn
booleaneq, neq
dateeq, neq, gt, lt, gte, lte
list[string]in, notIn
list[number]in, notIn

  • OpenAPI Spec: governance-service/docs/swagger.yaml (authoritative request/response shapes)
  • Go Types:
    • governance-service/pkg/core/indicators.go — indicator template and declaration profile
    • governance-service/pkg/core/applications.go — indicator application
    • governance-service/pkg/core/events.go — event ingestion request/response
    • governance-service/pkg/core/evaluation.go — criteria, operators, evaluation results
  • API Handlers:
    • governance-service/pkg/handlers/indicators.go — project indicator + application management
    • governance-service/pkg/handlers/applications.go — evaluation result endpoints
    • governance-service/pkg/handlers/events_ingestion.go — event ingestion endpoint
  • Routing: governance-service/pkg/routes/routes.go — v2 route registration

The Indicators system follows a three-step process:

  1. Create Indicator → A project-owned template that defines what to monitor and how to evaluate it (extraction rules, criteria, declaration profile)
  2. Apply to Control(s) → Status-only applications that switch the indicator on for specific controls
  3. Send Events → Routed by projectId + indicatorId, fanned out to all active applications for evaluation and evidence

This separation allows:

  • ✅ Reusing one indicator across multiple controls
  • ✅ Per-control on/off control via application status
  • ✅ Centralized event ingestion with fan-out routing
  • ✅ Automatic declaration generation with templated messaging

Key Takeaway: The indicator is the template (identity + extraction + criteria + declaration); applications are the status-only links that decide which controls it runs on.