---
id: CommitAdjusted
name: Commit Adjusted
version: 1.0.0
summary: Published when a manager overrides a submitted forecast for someone in their team.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Fired when a manager changes a rep's number through `POST /tenants/{tenant_id}/forecast/adjust`, guarded by `require_adjust_permission`. The adjustment is stored as a new `fact_forecast_revision` row, so the original submission is preserved.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` (but see the service page - this repo defaults to `rio-commit-events`) |
| `source` | `rio.commit` (`api/events/schemas.py:11`) |
| `detail-type` | `Commit Adjusted` (`api/events/schemas.py:21`) |
| `detail.event_name` | `rio.commit.commit.adjusted` |
| `detail.entity_type` | `commit` |
| `detail.action` | `adjusted` |
| Emitted at | `api/services/forecast_service.py:2083-2088` |
### What the payload carries
Only the audit envelope. The rich domain fields (amounts, breakdowns, quarter labels) are **dropped** before publishing - see the service page. `before` and `after` are always `null`.
### Who consumes it
Only the Audit Service, through its catch-all rule matching any `source` beginning with `rio`. No service subscribes to it specifically.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CommitAdjusted",
"description": "Published when a manager overrides a submitted forecast for someone in their team. Envelope built by _enrich_event_detail (rio-commit-service/api/events/eventbridge.py:36-96) and published on source `rio.commit` with detail-type `Commit Adjusted`.",
"type": "object",
"properties": {
"event_id": { "type": "string", "format": "uuid" },
"event_name": { "type": "string", "const": "rio.commit.commit.adjusted" },
"source_service": { "type": "string", "const": "rio.commit" },
"tenant_id": { "type": "string" },
"domain": { "type": "string", "const": "commit" },
"subdomain": { "type": "string", "const": "commit" },
"entity_type": { "type": "string", "const": "commit" },
"entity_id": { "type": "string", "description": "The forecast submission id being adjusted." },
"action": { "type": "string", "const": "adjusted" },
"status": { "type": "string", "default": "success" },
"actor_type": { "type": "string", "const": "user" },
"actor_id": { "type": "string" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": ["string", "null"], "description": "Taken from EventContextMiddleware contextvars." },
"request_id": { "type": ["string", "null"] },
"before": { "type": "null", "description": "Always null - the domain models never populate it." },
"after": { "type": "null", "description": "Always null - the domain models never populate it." },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: CommitFinalized
name: Commit Finalized
version: 1.0.0
summary: Published when a forecast submission is locked in for the cycle.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Emitted alongside `Commit Submitted` on the same submit call. Where `Commit Submitted` records the act of submitting, this one records that the value is now the settled figure for that person, quarter, cadence and revenue type.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` (but see the service page - this repo defaults to `rio-commit-events`) |
| `source` | `rio.commit` (`api/events/schemas.py:11`) |
| `detail-type` | `Commit Finalized` (`api/events/schemas.py:22`) |
| `detail.event_name` | `rio.commit.commit.finalized` |
| `detail.entity_type` | `commit` |
| `detail.action` | `finalized` |
| Emitted at | `api/services/forecast_service.py:1880-1885` |
### What the payload carries
Only the audit envelope. The rich domain fields (amounts, breakdowns, quarter labels) are **dropped** before publishing - see the service page. `before` and `after` are always `null`.
### Who consumes it
Only the Audit Service, through its catch-all rule matching any `source` beginning with `rio`. No service subscribes to it specifically.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CommitFinalized",
"description": "Published when a forecast submission is locked in for the cycle. Envelope built by _enrich_event_detail (rio-commit-service/api/events/eventbridge.py:36-96) and published on source `rio.commit` with detail-type `Commit Finalized`.",
"type": "object",
"properties": {
"event_id": { "type": "string", "format": "uuid" },
"event_name": { "type": "string", "const": "rio.commit.commit.finalized" },
"source_service": { "type": "string", "const": "rio.commit" },
"tenant_id": { "type": "string" },
"domain": { "type": "string", "const": "commit" },
"subdomain": { "type": "string", "const": "commit" },
"entity_type": { "type": "string", "const": "commit" },
"entity_id": { "type": "string", "description": "The forecast submission id." },
"action": { "type": "string", "const": "finalized" },
"status": { "type": "string", "default": "success" },
"actor_type": { "type": "string", "const": "user" },
"actor_id": { "type": "string" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": ["string", "null"], "description": "Taken from EventContextMiddleware contextvars." },
"request_id": { "type": ["string", "null"] },
"before": { "type": "null", "description": "Always null - the domain models never populate it." },
"after": { "type": "null", "description": "Always null - the domain models never populate it." },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: CommitSubmitted
name: Commit Submitted
version: 1.0.0
summary: Published when a sales rep submits their forecast commitment for a quarter.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Fired when a rep submits their numbers through `POST /tenants/{tenant_id}/forecast/submit`. The endpoint is guarded by `require_submit_permission`, and a single submit call emits **both** this event and `Commit Finalized`.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` (but see the service page - this repo defaults to `rio-commit-events`) |
| `source` | `rio.commit` (`api/events/schemas.py:11`) |
| `detail-type` | `Commit Submitted` (`api/events/schemas.py:20`) |
| `detail.event_name` | `rio.commit.commit.submitted` |
| `detail.entity_type` | `commit` |
| `detail.action` | `submitted` |
| Emitted at | `api/services/forecast_service.py:1859-1864` |
### What the payload carries
Only the audit envelope. The rich domain fields (amounts, breakdowns, quarter labels) are **dropped** before publishing - see the service page. `before` and `after` are always `null`.
### Who consumes it
Only the Audit Service, through its catch-all rule matching any `source` beginning with `rio`. No service subscribes to it specifically.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CommitSubmitted",
"description": "Published when a sales rep submits their forecast commitment for a quarter. Envelope built by _enrich_event_detail (rio-commit-service/api/events/eventbridge.py:36-96) and published on source `rio.commit` with detail-type `Commit Submitted`.",
"type": "object",
"properties": {
"event_id": { "type": "string", "format": "uuid" },
"event_name": { "type": "string", "const": "rio.commit.commit.submitted" },
"source_service": { "type": "string", "const": "rio.commit" },
"tenant_id": { "type": "string" },
"domain": { "type": "string", "const": "commit" },
"subdomain": { "type": "string", "const": "commit" },
"entity_type": { "type": "string", "const": "commit" },
"entity_id": { "type": "string", "description": "The forecast submission id." },
"action": { "type": "string", "const": "submitted" },
"status": { "type": "string", "default": "success" },
"actor_type": { "type": "string", "const": "user" },
"actor_id": { "type": "string" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": ["string", "null"], "description": "Taken from EventContextMiddleware contextvars." },
"request_id": { "type": ["string", "null"] },
"before": { "type": "null", "description": "Always null - the domain models never populate it." },
"after": { "type": "null", "description": "Always null - the domain models never populate it." },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: CreateNotification
name: Create Notification
version: 1.0.0
summary: A request to send a notification. This is the service's public "please notify someone" entry point.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
This is the one event in RIO that is designed to be published by **anyone**. The rule that picks it up filters on `detail-type` alone, with no `source` filter (`infrastructure/template.yaml:398-405`), so any service on the bus can ask for a notification to be sent.`n`nIt does not use the standard audit envelope. Its payload is small and practical: who to tell, what to say, and over which channel.`n`nEntries are batched into a single `put_events` call (`service.py:564`).
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.forecast-notification` |
| `detail-type` | `Create Notification` |
| `detail.event_name` | _(none - this event uses its own compact shape, not the audit envelope)_ |
| Emitted at | `lambda/src/forecast_notification/service.py:671` and `:733` |
### Who consumes it
The dispatch Lambda in this same service, via `CreateNotificationRule`. It then delivers over SES or Google Chat and writes the attempt to the DynamoDB log table.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CreateNotification",
"description": "A request to send a notification. This is the service's public "please notify someone" entry point. Published on source `rio.forecast-notification` with detail-type `Create Notification`.",
"type": "object",
"properties": {
"notification_id": { "type": "string" },
"correlation_id": { "type": "string", "description": "Ties the request to the delivery attempt and any later audit event." },
"subject": { "type": "string" },
"body": { "type": "string" },
"channels": {
"type": "array",
"description": "One entry per delivery channel.",
"items": {
"type": "object",
"properties": {
"channel_type": { "type": "string", "examples": ["ses"] },
"recipients": { "type": "array", "items": { "type": "string" } }
}
}
}
},
"required": ["notification_id","correlation_id","subject","body","channels"]
}
---
id: CRMSyncCompleted
name: CRM Sync Completed
version: 1.0.0
summary: Published when every table in the run finished successfully.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
The success terminator for a run. It is emitted **only if every one of the 29 tables succeeded**. If any table failed, the pipeline emits `CRM Sync Failed` instead and raises an error.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.platform` (`dependencies/audit_events.py:36`) |
| `detail-type` | `CRM Sync Completed` (`audit_events.py:45`) |
| `detail.event_name` | `rio.platform.ingestion.crm_sync.completed` |
| `detail.entity_type` | `crm_sync` |
| `detail.action` | `completed` |
| Emitted at | `audit_events.py:305` (`emit_crm_sync_completed`) |
### Tracing a run
Every event from a single pipeline run carries the same `correlation_id`, generated at the start of
the run. Filter the audit trail on that value to see the whole sync in order.
### Who consumes it
Only the Audit Service, via its catch-all rule matching any `source` beginning with `rio`. No service subscribes to it specifically.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CRMSyncCompleted",
"description": "Published when every table in the run finished successfully. Published on source `rio.platform` with detail-type `CRM Sync Completed`. Shape is the AuditEventDetail dataclass, rio-ingestion-service/jobs/crm-data-sync/dependencies/audit_events.py:100-136. Null values are dropped by to_dict().",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.platform.ingestion.crm_sync.completed" },
"source_service": { "type": "string", "const": "rio.platform" },
"tenant_id": { "type": ["string", "null"] },
"domain": { "type": "string", "const": "platform" },
"subdomain": { "type": "string", "const": "ingestion" },
"entity_type": { "type": "string", "const": "crm_sync" },
"entity_id": { "type": "string" },
"action": { "type": "string", "const": "completed" },
"status": { "type": "string", "enum": ["success", "failed", "rejected", "in_progress"] },
"actor_type": { "type": "string", "enum": ["crm", "system", "user"], "default": "crm" },
"actor_id": { "type": "string", "default": "rio-platform-ingestion-service" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": "string", "description": "Generated once per run and stamped on every event from that run - this is how you trace a whole sync." },
"request_id": { "type": "string" },
"before": { "type": ["object", "null"] },
"after": { "type": ["object", "null"] },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": {
"type": ["object", "null"],
"description": "Per-event extras.",
"properties": {
"table_count": { "type": "integer", "description": "Number of tables in the run (28)." },
"failed_count": { "type": "integer" },
"failed_tables": { "type": "array", "items": { "type": "string" } },
"signal_source": { "type": "string" },
"rows_processed": { "type": "integer" }
}
}
},
"required": ["event_id","event_name","source_service","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: CRMSyncFailed
name: CRM Sync Failed
version: 1.0.0
summary: Published when one or more tables failed during the run. Lists which ones.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
The failure terminator. `metadata.failed_count` and `metadata.failed_tables` name exactly which tables did not make it, so you do not have to read logs to find out.`n`nImportant behaviour: a single table failing does **not** stop the run. The pipeline continues to the next table and only reports the aggregate failure at the end.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.platform` (`dependencies/audit_events.py:36`) |
| `detail-type` | `CRM Sync Failed` (`audit_events.py:46`) |
| `detail.event_name` | `rio.platform.ingestion.crm_sync.failed` |
| `detail.entity_type` | `crm_sync` |
| `detail.action` | `failed` |
| Emitted at | `audit_events.py:327` (`emit_crm_sync_failed`) |
### Tracing a run
Every event from a single pipeline run carries the same `correlation_id`, generated at the start of
the run. Filter the audit trail on that value to see the whole sync in order.
### Who consumes it
Only the Audit Service, via its catch-all rule matching any `source` beginning with `rio`. No service subscribes to it specifically.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CRMSyncFailed",
"description": "Published when one or more tables failed during the run. Lists which ones. Published on source `rio.platform` with detail-type `CRM Sync Failed`. Shape is the AuditEventDetail dataclass, rio-ingestion-service/jobs/crm-data-sync/dependencies/audit_events.py:100-136. Null values are dropped by to_dict().",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.platform.ingestion.crm_sync.failed" },
"source_service": { "type": "string", "const": "rio.platform" },
"tenant_id": { "type": ["string", "null"] },
"domain": { "type": "string", "const": "platform" },
"subdomain": { "type": "string", "const": "ingestion" },
"entity_type": { "type": "string", "const": "crm_sync" },
"entity_id": { "type": "string" },
"action": { "type": "string", "const": "failed" },
"status": { "type": "string", "enum": ["success", "failed", "rejected", "in_progress"] },
"actor_type": { "type": "string", "enum": ["crm", "system", "user"], "default": "crm" },
"actor_id": { "type": "string", "default": "rio-platform-ingestion-service" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": "string", "description": "Generated once per run and stamped on every event from that run - this is how you trace a whole sync." },
"request_id": { "type": "string" },
"before": { "type": ["object", "null"] },
"after": { "type": ["object", "null"] },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": {
"type": ["object", "null"],
"description": "Per-event extras.",
"properties": {
"table_count": { "type": "integer", "description": "Number of tables in the run (28)." },
"failed_count": { "type": "integer" },
"failed_tables": { "type": "array", "items": { "type": "string" } },
"signal_source": { "type": "string" },
"rows_processed": { "type": "integer" }
}
}
},
"required": ["event_id","event_name","source_service","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: CRMSyncStarted
name: CRM Sync Started
version: 1.0.0
summary: Announces that a CRM sync run has begun, tagged with how many tables it will process.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Emitted once the pipeline has connected to its dependencies and is about to start the table loop. `metadata.table_count` records how many tables the run will attempt - currently 28.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.platform` (`dependencies/audit_events.py:36`) |
| `detail-type` | `CRM Sync Started` (`audit_events.py:44`) |
| `detail.event_name` | `rio.platform.ingestion.crm_sync.started` |
| `detail.entity_type` | `crm_sync` |
| `detail.action` | `started` |
| Emitted at | `audit_events.py:284` (`emit_crm_sync_started`) |
### Tracing a run
Every event from a single pipeline run carries the same `correlation_id`, generated at the start of
the run. Filter the audit trail on that value to see the whole sync in order.
### Who consumes it
Only the Audit Service, via its catch-all rule matching any `source` beginning with `rio`. No service subscribes to it specifically.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CRMSyncStarted",
"description": "Announces that a CRM sync run has begun, tagged with how many tables it will process. Published on source `rio.platform` with detail-type `CRM Sync Started`. Shape is the AuditEventDetail dataclass, rio-ingestion-service/jobs/crm-data-sync/dependencies/audit_events.py:100-136. Null values are dropped by to_dict().",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.platform.ingestion.crm_sync.started" },
"source_service": { "type": "string", "const": "rio.platform" },
"tenant_id": { "type": ["string", "null"] },
"domain": { "type": "string", "const": "platform" },
"subdomain": { "type": "string", "const": "ingestion" },
"entity_type": { "type": "string", "const": "crm_sync" },
"entity_id": { "type": "string" },
"action": { "type": "string", "const": "started" },
"status": { "type": "string", "enum": ["success", "failed", "rejected", "in_progress"] },
"actor_type": { "type": "string", "enum": ["crm", "system", "user"], "default": "crm" },
"actor_id": { "type": "string", "default": "rio-platform-ingestion-service" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": "string", "description": "Generated once per run and stamped on every event from that run - this is how you trace a whole sync." },
"request_id": { "type": "string" },
"before": { "type": ["object", "null"] },
"after": { "type": ["object", "null"] },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": {
"type": ["object", "null"],
"description": "Per-event extras.",
"properties": {
"table_count": { "type": "integer", "description": "Number of tables in the run (28)." },
"failed_count": { "type": "integer" },
"failed_tables": { "type": "array", "items": { "type": "string" } },
"signal_source": { "type": "string" },
"rows_processed": { "type": "integer" }
}
}
},
"required": ["event_id","event_name","source_service","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: EmailDistillationFailed
name: Email Distillation Failed
version: 1.0.0
summary: Published when the LLM distillation stage fails to summarise a thread into learnings.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Distillation is the LLM step that turns a raw email thread into structured learnings. This event is raised when that step fails for a run.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.activity` (`lambdas/_shared/events.py:19`) |
| `detail-type` | `Email Distillation Failed` (`lambdas/_shared/events.py:20`) |
| Emitted at | `lambdas/batch_assembler/handler.py:826` |
### Who consumes it
Nothing subscribes to this event. It is an operational alert that lands in the Audit Service via the catch-all rule, and is otherwise available for a future alerting rule. Unlike the two email **ingestion** failures, it does **not** carry `target_service`, so it does not reach the Notification Service.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmailDistillationFailed",
"description": "Published when the LLM distillation stage fails to summarise a thread into learnings. Pipeline alert shape from rio-deal-desk/lambdas/_shared/events.py:30-45. This is NOT the audit envelope.",
"type": "object",
"properties": {
"event_version": { "type": "integer", "const": 1 },
"run_id": { "type": "string", "description": "Identifies the pipeline run this failure belongs to." },
"stage": { "type": "string", "description": "Pipeline stage name.", "const": "distillation" },
"job_arn": { "type": ["string", "null"], "description": "ARN of the underlying job, when there is one." },
"error": { "type": "string", "description": "Error text, truncated to 1000 characters." },
"occurred_at": { "type": "string", "format": "date-time" }
},
"required": ["event_version","run_id","stage","error","occurred_at"]
}
---
id: EmailEmbeddingFailed
name: Email Embedding Failed
version: 1.0.0
summary: Published when learnings cannot be turned into vector embeddings.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Embeddings power similarity search over past deals - they are written to ClickHouse columns backed by HNSW vector indexes. This event is raised when that generation step fails.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.activity` (`lambdas/_shared/events.py:19`) |
| `detail-type` | `Email Embedding Failed` (`lambdas/_shared/events.py:21`) |
| Emitted at | `lambdas/batch_assembler/handler.py:830` |
### Who consumes it
Nothing subscribes to this event. It is an operational alert that lands in the Audit Service via the catch-all rule, and is otherwise available for a future alerting rule. Unlike the two email **ingestion** failures, it does **not** carry `target_service`, so it does not reach the Notification Service.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmailEmbeddingFailed",
"description": "Published when learnings cannot be turned into vector embeddings. Pipeline alert shape from rio-deal-desk/lambdas/_shared/events.py:30-45. This is NOT the audit envelope.",
"type": "object",
"properties": {
"event_version": { "type": "integer", "const": 1 },
"run_id": { "type": "string", "description": "Identifies the pipeline run this failure belongs to." },
"stage": { "type": "string", "description": "Pipeline stage name.", "const": "embedding" },
"job_arn": { "type": ["string", "null"], "description": "ARN of the underlying job, when there is one." },
"error": { "type": "string", "description": "Error text, truncated to 1000 characters." },
"occurred_at": { "type": "string", "format": "date-time" }
},
"required": ["event_version","run_id","stage","error","occurred_at"]
}
---
id: EmailIngestionFailed
name: Email Ingestion Failed
version: 1.0.0
summary: Published when message ingestion fails. Triggers an operational alert.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
The second of the two events that reach the Notification Service, using the same `target_service` routing trick as `Email Sync Failed`. Because ingestion failed, no attachment download is triggered.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.core.activity` (`lambdas/email_ingestion/events.py:20`) |
| `detail-type` | `Email Ingestion Failed` (`lambdas/email_ingestion/events.py:22`) |
| `detail.event_name` | `rio.core.activity.emailingestionfailed` (`events.py:86`) |
| Emitted at | `lambdas/email_ingestion/events.py:307` (`publish_ingestion_failed`) |
### Who consumes it
The Notification Service dispatch Lambda. Also the Audit Service catch-all.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmailIngestionFailed",
"description": "Published when message ingestion fails. Triggers an operational alert. Built by _build_email_event_detail, rio-deal-desk/lambdas/email_ingestion/events.py:268-294.",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Format evt_{32 hex chars}." },
"event_name": { "type": "string", "const": "rio.core.activity.emailingestionfailed" },
"source_service": { "type": "string", "const": "rio.core.activity" },
"tenant_id": { "type": ["string", "null"] },
"domain": { "type": "string", "const": "core" },
"subdomain": { "type": "string", "const": "activity" },
"entity_type": { "type": "string", "const": "email" },
"entity_id": { "type": "string", "description": "The OAuth connection id." },
"action": { "type": "string", "enum": ["sync", "ingestion", "attachment_download"] },
"status": { "type": "string", "const": "FAILED" },
"actor_type": { "type": "string", "const": "INTEGRATION" },
"actor_id": { "type": "string", "description": "The entity_id, uppercased." },
"severity": { "type": "string", "const": "HIGH" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": ["string", "null"] },
"request_id": { "type": ["string", "null"] },
"before": { "type": "object" },
"after": { "type": "object" },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"], "description": "Truncated to 1000 characters." },
"event_version": { "type": "integer", "const": 1 },
"target_service": { "type": "string", "const": "rio.platform.notification", "description": "Present on failure events only. This is the field the Notification Service rule matches on." },
"metadata": {
"type": "object",
"description": "Built by _base_metadata (events.py:233-249).",
"properties": {
"provider": { "type": "string", "const": "google" },
"workspace_id": { "type": ["string", "null"] },
"user_id": { "type": "string" },
"authenticated_email": { "type": "string" },
"sync_type": { "type": "string" }
}
}
},
"required": ["event_id","event_name","source_service","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: EmailIngestionSucceeded
name: Email Ingestion Succeeded
version: 1.0.0
summary: Published when messages have been ingested successfully. Kicks off attachment download.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
The only **success** event in this service with a functional consumer. It triggers the attachment download state machine, so attachments are only fetched once their parent messages are safely stored.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.core.activity` (`lambdas/email_ingestion/events.py:20`) |
| `detail-type` | `Email Ingestion Succeeded` (`lambdas/email_ingestion/events.py:24`) |
| `detail.event_name` | `rio.core.activity.emailingestionsucceeded` (`events.py:212`) |
| Emitted at | `lambdas/email_ingestion/events.py:313` (`publish_ingestion_succeeded`) |
### Who consumes it
This service's own attachment state machine, via `${DeployPrefix}-attachment-on-ingestion` (`infrastructure/ingestion/template.yaml:548-562`). Also the Audit Service catch-all.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmailIngestionSucceeded",
"description": "Published when messages have been ingested successfully. Kicks off attachment download. Built by _build_email_event_detail, rio-deal-desk/lambdas/email_ingestion/events.py:268-294.",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Format evt_{32 hex chars}." },
"event_name": { "type": "string", "const": "rio.core.activity.emailingestionsucceeded" },
"source_service": { "type": "string", "const": "rio.core.activity" },
"tenant_id": { "type": ["string", "null"] },
"domain": { "type": "string", "const": "core" },
"subdomain": { "type": "string", "const": "activity" },
"entity_type": { "type": "string", "const": "email" },
"entity_id": { "type": "string", "description": "The OAuth connection id." },
"action": { "type": "string", "enum": ["sync", "ingestion", "attachment_download"] },
"status": { "type": "string", "const": "SUCCESS" },
"actor_type": { "type": "string", "const": "INTEGRATION" },
"actor_id": { "type": "string", "description": "The entity_id, uppercased." },
"severity": { "type": "string", "const": "LOW" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": ["string", "null"] },
"request_id": { "type": ["string", "null"] },
"before": { "type": "object" },
"after": { "type": "object" },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"], "description": "Truncated to 1000 characters." },
"event_version": { "type": "integer", "const": 1 },
"metadata": {
"type": "object",
"description": "Built by _base_metadata (events.py:233-249).",
"properties": {
"provider": { "type": "string", "const": "google" },
"workspace_id": { "type": ["string", "null"] },
"user_id": { "type": "string" },
"authenticated_email": { "type": "string" },
"sync_type": { "type": "string" }
}
}
},
"required": ["event_id","event_name","source_service","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: EmailParsingFailed
name: Email Parsing Failed
version: 1.0.0
summary: Published when the parsing stage cannot extract structured content from a message.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Raised when parsing fails for a pipeline run. Like the other pipeline alerts it uses the small failure shape - `run_id`, `stage`, `job_arn`, `error`, `occurred_at` - not the audit envelope.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.activity` (`lambdas/_shared/events.py:19`) |
| `detail-type` | `Email Parsing Failed` (`lambdas/_shared/events.py:24`) |
| Emitted at | `lambdas/batch_assembler/handler.py:822` |
### Who consumes it
Nothing subscribes to this event. It is an operational alert that lands in the Audit Service via the catch-all rule, and is otherwise available for a future alerting rule. Unlike the two email **ingestion** failures, it does **not** carry `target_service`, so it does not reach the Notification Service.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmailParsingFailed",
"description": "Published when the parsing stage cannot extract structured content from a message. Pipeline alert shape from rio-deal-desk/lambdas/_shared/events.py:30-45. This is NOT the audit envelope.",
"type": "object",
"properties": {
"event_version": { "type": "integer", "const": 1 },
"run_id": { "type": "string", "description": "Identifies the pipeline run this failure belongs to." },
"stage": { "type": "string", "description": "Pipeline stage name.", "const": "parsing" },
"job_arn": { "type": ["string", "null"], "description": "ARN of the underlying job, when there is one." },
"error": { "type": "string", "description": "Error text, truncated to 1000 characters." },
"occurred_at": { "type": "string", "format": "date-time" }
},
"required": ["event_version","run_id","stage","error","occurred_at"]
}
---
id: EmailResolutionFailed
name: Email Resolution Failed
version: 1.0.0
summary: Published when the pipeline cannot work out which product, opportunity or region a thread belongs to.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Resolution decides what a thread is actually about. Failures are also written to the `resolution_failure` Postgres table with a `failure_stage` of `s3_read`, `llm`, `resolve` or `persist`, so this event and that table should agree.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.activity` (`lambdas/_shared/events.py:19`) |
| `detail-type` | `Email Resolution Failed` (`lambdas/_shared/events.py:25`) |
| Emitted at | `lambdas/batch_assembler/handler.py:828` and `lambdas/resolution/handler.py:223` |
### Who consumes it
Nothing subscribes to this event. It is an operational alert that lands in the Audit Service via the catch-all rule, and is otherwise available for a future alerting rule. Unlike the two email **ingestion** failures, it does **not** carry `target_service`, so it does not reach the Notification Service.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmailResolutionFailed",
"description": "Published when the pipeline cannot work out which product, opportunity or region a thread belongs to. Pipeline alert shape from rio-deal-desk/lambdas/_shared/events.py:30-45. This is NOT the audit envelope.",
"type": "object",
"properties": {
"event_version": { "type": "integer", "const": 1 },
"run_id": { "type": "string", "description": "Identifies the pipeline run this failure belongs to." },
"stage": { "type": "string", "description": "Pipeline stage name.", "const": "resolution" },
"job_arn": { "type": ["string", "null"], "description": "ARN of the underlying job, when there is one." },
"error": { "type": "string", "description": "Error text, truncated to 1000 characters." },
"occurred_at": { "type": "string", "format": "date-time" }
},
"required": ["event_version","run_id","stage","error","occurred_at"]
}
---
id: EmailResolutionSummary
name: Email Resolution Summary
version: 1.0.0
summary: A per-run tally of how many email threads were resolved, deferred or failed. The only reporting event in the pipeline.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Every other pipeline event says "this one thing failed". This one reports on the run as a whole.
The resolution stage decides which product, opportunity and region each email thread is about. Some
threads resolve cleanly, some are deferred for lack of evidence, and some fail outright. Once the
stage finishes, this event publishes the counts.
It is the natural thing to build a dashboard or an alert threshold on — for example, "tell me if more
than 20% of threads deferred in a run".
### What it reports
| Field | Meaning |
|---|---|
| `threads` | How many threads the run looked at |
| `resolved` | Resolved cleanly |
| `deferred` | Not enough evidence to decide; will be retried |
| `failed` | Failed outright |
| `file_failures` | Failures reading the source file from S3 |
| `llm_failures` | Failures in the LLM call |
| `failed_thread_ids` | The specific thread ids that failed — **capped at 100** |
Note the cap: if a run has more than 100 failures, `failed_thread_ids` is truncated while the `failed`
count stays accurate. Use the `resolution_failure` Postgres table for the complete list.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.activity` (`lambdas/_shared/events.py:19`) |
| `detail-type` | `Email Resolution Summary` (`lambdas/_shared/events.py:26`) |
| Emitted at | `lambdas/resolution/handler.py:242` |
### Who consumes it
Nothing subscribes to it today. It reaches the
Audit Service via the catch-all rule and
is otherwise available for monitoring.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmailResolutionSummary",
"description": "Per-run tally from the email resolution stage. Shape from rio-deal-desk/lambdas/_shared/events.py:48-79.",
"type": "object",
"properties": {
"event_version": { "type": "integer", "const": 1 },
"run_id": { "type": "string", "description": "Identifies the pipeline run being summarised." },
"stage": { "type": "string", "const": "resolution" },
"threads": { "type": "integer", "description": "Total threads the run processed." },
"resolved": { "type": "integer", "description": "Threads resolved to a product / opportunity / region." },
"deferred": { "type": "integer", "description": "Threads with insufficient evidence, held for a later run." },
"failed": { "type": "integer", "description": "Threads that failed outright. Accurate even when failed_thread_ids is truncated." },
"file_failures": { "type": "integer", "description": "Failures reading the source artifact from S3." },
"llm_failures": { "type": "integer", "description": "Failures in the LLM resolution call." },
"failed_thread_ids": {
"type": "array",
"items": { "type": "string" },
"maxItems": 100,
"description": "Ids of failed threads, CAPPED AT 100. For the full list query the resolution_failure Postgres table."
},
"occurred_at": { "type": "string", "format": "date-time" }
},
"required": ["event_version", "run_id", "stage", "threads", "resolved", "deferred", "failed", "occurred_at"]
}
---
id: EmailSyncFailed
name: Email Sync Failed
version: 1.0.0
summary: Published when a Gmail sync run fails. Triggers an operational alert.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
One of only two events in RIO that reach the Notification Service. It stamps `target_service: "rio.platform.notification"` into its payload, and the Notification Service rule matches on **that field plus `detail.event_name`** rather than on `source` or `detail-type`.`n`nThe attachment-download failure path reuses this same detail-type with a different `event_name` (`rio.core.activity.emailattachmentdownloadfailed`, `events.py:144`), adding `stage`, `message_id`, `attachment_id` and `filename` to `metadata`.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.core.activity` (`lambdas/email_ingestion/events.py:20`) |
| `detail-type` | `Email Sync Failed` (`lambdas/email_ingestion/events.py:21`) |
| `detail.event_name` | `rio.core.activity.emailsyncfailed` (`events.py:50`) |
| Emitted at | `lambdas/email_ingestion/events.py:304` (`publish_sync_failed`) |
### Who consumes it
The Notification Service dispatch Lambda, which emails the technical list and posts to the alert channels. Also the Audit Service catch-all.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmailSyncFailed",
"description": "Published when a Gmail sync run fails. Triggers an operational alert. Built by _build_email_event_detail, rio-deal-desk/lambdas/email_ingestion/events.py:268-294.",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Format evt_{32 hex chars}." },
"event_name": { "type": "string", "const": "rio.core.activity.emailsyncfailed" },
"source_service": { "type": "string", "const": "rio.core.activity" },
"tenant_id": { "type": ["string", "null"] },
"domain": { "type": "string", "const": "core" },
"subdomain": { "type": "string", "const": "activity" },
"entity_type": { "type": "string", "const": "email" },
"entity_id": { "type": "string", "description": "The OAuth connection id." },
"action": { "type": "string", "enum": ["sync", "ingestion", "attachment_download"] },
"status": { "type": "string", "const": "FAILED" },
"actor_type": { "type": "string", "const": "INTEGRATION" },
"actor_id": { "type": "string", "description": "The entity_id, uppercased." },
"severity": { "type": "string", "const": "HIGH" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": ["string", "null"] },
"request_id": { "type": ["string", "null"] },
"before": { "type": "object" },
"after": { "type": "object" },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"], "description": "Truncated to 1000 characters." },
"event_version": { "type": "integer", "const": 1 },
"target_service": { "type": "string", "const": "rio.platform.notification", "description": "Present on failure events only. This is the field the Notification Service rule matches on." },
"metadata": {
"type": "object",
"description": "Built by _base_metadata (events.py:233-249).",
"properties": {
"provider": { "type": "string", "const": "google" },
"workspace_id": { "type": ["string", "null"] },
"user_id": { "type": "string" },
"authenticated_email": { "type": "string" },
"sync_type": { "type": "string" }
}
}
},
"required": ["event_id","event_name","source_service","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: EmailSyncSucceeded
name: Email Sync Succeeded
version: 1.0.0
summary: Published when a Gmail sync run for one connection finishes without error.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Records that a scheduled Gmail sync completed. Carries the connection id as `entity_id` and sync context in `metadata`.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.core.activity` (`lambdas/email_ingestion/events.py:20`) |
| `detail-type` | `Email Sync Succeeded` (`lambdas/email_ingestion/events.py:23`) |
| `detail.event_name` | `rio.core.activity.emailsyncsucceeded` (`events.py:178`) |
| Emitted at | `lambdas/email_ingestion/events.py:310` (`publish_sync_succeeded`) |
### Who consumes it
Only the Audit Service, via its catch-all rule. Unlike its failure counterpart it carries no `target_service`, so it does not reach the Notification Service.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmailSyncSucceeded",
"description": "Published when a Gmail sync run for one connection finishes without error. Built by _build_email_event_detail, rio-deal-desk/lambdas/email_ingestion/events.py:268-294.",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Format evt_{32 hex chars}." },
"event_name": { "type": "string", "const": "rio.core.activity.emailsyncsucceeded" },
"source_service": { "type": "string", "const": "rio.core.activity" },
"tenant_id": { "type": ["string", "null"] },
"domain": { "type": "string", "const": "core" },
"subdomain": { "type": "string", "const": "activity" },
"entity_type": { "type": "string", "const": "email" },
"entity_id": { "type": "string", "description": "The OAuth connection id." },
"action": { "type": "string", "enum": ["sync", "ingestion", "attachment_download"] },
"status": { "type": "string", "const": "SUCCESS" },
"actor_type": { "type": "string", "const": "INTEGRATION" },
"actor_id": { "type": "string", "description": "The entity_id, uppercased." },
"severity": { "type": "string", "const": "LOW" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": ["string", "null"] },
"request_id": { "type": ["string", "null"] },
"before": { "type": "object" },
"after": { "type": "object" },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"], "description": "Truncated to 1000 characters." },
"event_version": { "type": "integer", "const": 1 },
"metadata": {
"type": "object",
"description": "Built by _base_metadata (events.py:233-249).",
"properties": {
"provider": { "type": "string", "const": "google" },
"workspace_id": { "type": ["string", "null"] },
"user_id": { "type": "string" },
"authenticated_email": { "type": "string" },
"sync_type": { "type": "string" }
}
}
},
"required": ["event_id","event_name","source_service","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: EmbeddingsBatchReady
name: Embeddings Batch Ready
version: 1.0.0
summary: Published when a batch of thread learnings has been embedded and is ready for the canonical learning stage.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
This is the hand-off between two stages of the pipeline. Once a batch of thread learnings has been
turned into vector embeddings, this event tells the canonical-learning stage that the batch is ready
to be grouped and de-duplicated.
It is one of only three events in the whole platform that a service publishes purely so another part
of itself can react.
### The payload has two shapes
EventBridge caps the size of a single entry, and a batch can contain a lot of ids. So the publisher
switches shape based on volume (`lambdas/batch_assembler/handler.py:943-969`):
| Condition | Payload |
|---|---|
| 1,000 ids or fewer | `{ run_id, thread_learning_ids: [...] }` — ids sent inline |
| more than 1,000 ids | `{ run_id, : "s3://..." }` — ids offloaded to S3, only a pointer sent |
**A consumer must handle both.** If you only read `thread_learning_ids`, large batches will look
empty rather than failing loudly.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.activity` (`lambdas/_shared/events.py:19`) |
| `detail-type` | `Embeddings Batch Ready` (`lambdas/_shared/events.py:22`) |
| Emitted at | `lambdas/batch_assembler/handler.py:943` (inline) and `:965` (S3 reference) |
### Who consumes it
This service's own canonical learning state machine, via `${DeployPrefix}-embeddings-ready`
(`infrastructure/distillation/template.yaml:793-808`). Also the
Audit Service catch-all.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "EmbeddingsBatchReady",
"description": "Published when a batch of thread learnings has been embedded. Shape from rio-deal-desk/lambdas/batch_assembler/handler.py:943-969. The payload has two variants depending on batch size - consumers must handle both.",
"type": "object",
"properties": {
"run_id": {
"type": "string",
"description": "Identifies the pipeline run that produced this batch."
},
"thread_learning_ids": {
"type": "array",
"items": { "type": "string" },
"maxItems": 1000,
"description": "Inline variant. Present only when the batch has 1000 ids or fewer."
},
"s3_payload_key": {
"type": "string",
"description": "Reference variant. Present instead of thread_learning_ids when the batch exceeds 1000 ids; the ids are stored in S3 at this key. The exact property name comes from the S3_PAYLOAD_KEY constant in the publishing module."
}
},
"required": ["run_id"],
"oneOf": [
{ "required": ["thread_learning_ids"] },
{ "required": ["s3_payload_key"] }
]
}
---
id: ETLBatchCompleted
name: ETL Batch Completed
version: 1.0.0
summary: Published once per table that loaded successfully.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Per-table success — one per table in the registry, so a run that completes cleanly produces 29 of them.
> **`rows_processed` is always `0`.** `emit_etl_batch_completed` accepts a `rows_processed` argument
> and stamps it into `metadata`, but `main.py` never passes one, so the default of `0` is what ships.
> Use the OpenTelemetry counters (`rio.ingestion.s3_queue_rows_sent`,
> `rio.ingestion.clickhouse_rows_received`) for actual volume, not this event.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.platform` (`dependencies/audit_events.py:36`) |
| `detail-type` | `ETL Batch Completed` (`audit_events.py:49`) |
| `detail.event_name` | `rio.platform.ingestion.etl_batch.completed` |
| `detail.entity_type` | `etl_batch` |
| `detail.action` | `completed` |
| Emitted at | `audit_events.py:392` (`emit_etl_batch_completed`) |
### Tracing a run
Every event from a single pipeline run carries the same `correlation_id`, generated at the start of
the run. Filter the audit trail on that value to see the whole sync in order.
### Who consumes it
Only the Audit Service, via its catch-all rule matching any `source` beginning with `rio`. No service subscribes to it specifically.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ETLBatchCompleted",
"description": "Published once per table that loaded successfully, with the row count. Published on source `rio.platform` with detail-type `ETL Batch Completed`. Shape is the AuditEventDetail dataclass, rio-ingestion-service/jobs/crm-data-sync/dependencies/audit_events.py:100-136. Null values are dropped by to_dict().",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.platform.ingestion.etl_batch.completed" },
"source_service": { "type": "string", "const": "rio.platform" },
"tenant_id": { "type": ["string", "null"] },
"domain": { "type": "string", "const": "platform" },
"subdomain": { "type": "string", "const": "ingestion" },
"entity_type": { "type": "string", "const": "etl_batch" },
"entity_id": { "type": "string" },
"action": { "type": "string", "const": "completed" },
"status": { "type": "string", "enum": ["success", "failed", "rejected", "in_progress"] },
"actor_type": { "type": "string", "enum": ["crm", "system", "user"], "default": "crm" },
"actor_id": { "type": "string", "default": "rio-platform-ingestion-service" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": "string", "description": "Generated once per run and stamped on every event from that run - this is how you trace a whole sync." },
"request_id": { "type": "string" },
"before": { "type": ["object", "null"] },
"after": { "type": ["object", "null"] },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": {
"type": ["object", "null"],
"description": "Per-event extras.",
"properties": {
"table_count": { "type": "integer", "description": "Number of tables in the run (28)." },
"failed_count": { "type": "integer" },
"failed_tables": { "type": "array", "items": { "type": "string" } },
"signal_source": { "type": "string" },
"rows_processed": { "type": "integer" }
}
}
},
"required": ["event_id","event_name","source_service","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: ETLBatchFailed
name: ETL Batch Failed
version: 1.0.0
summary: Published once per table that failed, with the error message. The run continues regardless.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Per-table failure, carrying `error_message`. The run does **not** stop - the pipeline moves on to the next table and reports the aggregate at the end via `CRM Sync Failed`.`n`nSo a single run can produce several of these plus a successful-looking set of `ETL Batch Completed` events.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.platform` (`dependencies/audit_events.py:36`) |
| `detail-type` | `ETL Batch Failed` (`audit_events.py:50`) |
| `detail.event_name` | `rio.platform.ingestion.etl_batch.failed` |
| `detail.entity_type` | `etl_batch` |
| `detail.action` | `failed` |
| Emitted at | `audit_events.py:414` (`emit_etl_batch_failed`) |
### Tracing a run
Every event from a single pipeline run carries the same `correlation_id`, generated at the start of
the run. Filter the audit trail on that value to see the whole sync in order.
### Who consumes it
Only the Audit Service, via its catch-all rule matching any `source` beginning with `rio`. No service subscribes to it specifically.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ETLBatchFailed",
"description": "Published once per table that failed, with the error message. The run continues regardless. Published on source `rio.platform` with detail-type `ETL Batch Failed`. Shape is the AuditEventDetail dataclass, rio-ingestion-service/jobs/crm-data-sync/dependencies/audit_events.py:100-136. Null values are dropped by to_dict().",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.platform.ingestion.etl_batch.failed" },
"source_service": { "type": "string", "const": "rio.platform" },
"tenant_id": { "type": ["string", "null"] },
"domain": { "type": "string", "const": "platform" },
"subdomain": { "type": "string", "const": "ingestion" },
"entity_type": { "type": "string", "const": "etl_batch" },
"entity_id": { "type": "string" },
"action": { "type": "string", "const": "failed" },
"status": { "type": "string", "enum": ["success", "failed", "rejected", "in_progress"] },
"actor_type": { "type": "string", "enum": ["crm", "system", "user"], "default": "crm" },
"actor_id": { "type": "string", "default": "rio-platform-ingestion-service" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": "string", "description": "Generated once per run and stamped on every event from that run - this is how you trace a whole sync." },
"request_id": { "type": "string" },
"before": { "type": ["object", "null"] },
"after": { "type": ["object", "null"] },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": {
"type": ["object", "null"],
"description": "Per-event extras.",
"properties": {
"table_count": { "type": "integer", "description": "Number of tables in the run (28)." },
"failed_count": { "type": "integer" },
"failed_tables": { "type": "array", "items": { "type": "string" } },
"signal_source": { "type": "string" },
"rows_processed": { "type": "integer" }
}
}
},
"required": ["event_id","event_name","source_service","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: ETLBatchStarted
name: ETL Batch Started
version: 1.0.0
summary: Published once per table, when that table begins extraction.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Emitted at the top of each table's turn in the loop - so roughly 29 times per run. At this point the pipeline has picked the oldest of the table's S3, ClickHouse and RDS watermarks as its extraction baseline.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.platform` (`dependencies/audit_events.py:36`) |
| `detail-type` | `ETL Batch Started` (`audit_events.py:48`) |
| `detail.event_name` | `rio.platform.ingestion.etl_batch.started` |
| `detail.entity_type` | `etl_batch` |
| `detail.action` | `started` |
| Emitted at | `audit_events.py:372` (`emit_etl_batch_started`) |
### Tracing a run
Every event from a single pipeline run carries the same `correlation_id`, generated at the start of
the run. Filter the audit trail on that value to see the whole sync in order.
### Who consumes it
Only the Audit Service, via its catch-all rule matching any `source` beginning with `rio`. No service subscribes to it specifically.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ETLBatchStarted",
"description": "Published once per table, when that table begins extraction. Published on source `rio.platform` with detail-type `ETL Batch Started`. Shape is the AuditEventDetail dataclass, rio-ingestion-service/jobs/crm-data-sync/dependencies/audit_events.py:100-136. Null values are dropped by to_dict().",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.platform.ingestion.etl_batch.started" },
"source_service": { "type": "string", "const": "rio.platform" },
"tenant_id": { "type": ["string", "null"] },
"domain": { "type": "string", "const": "platform" },
"subdomain": { "type": "string", "const": "ingestion" },
"entity_type": { "type": "string", "const": "etl_batch" },
"entity_id": { "type": "string" },
"action": { "type": "string", "const": "started" },
"status": { "type": "string", "enum": ["success", "failed", "rejected", "in_progress"] },
"actor_type": { "type": "string", "enum": ["crm", "system", "user"], "default": "crm" },
"actor_id": { "type": "string", "default": "rio-platform-ingestion-service" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": "string", "description": "Generated once per run and stamped on every event from that run - this is how you trace a whole sync." },
"request_id": { "type": "string" },
"before": { "type": ["object", "null"] },
"after": { "type": ["object", "null"] },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": {
"type": ["object", "null"],
"description": "Per-event extras.",
"properties": {
"table_count": { "type": "integer", "description": "Number of tables in the run (28)." },
"failed_count": { "type": "integer" },
"failed_tables": { "type": "array", "items": { "type": "string" } },
"signal_source": { "type": "string" },
"rows_processed": { "type": "integer" }
}
}
},
"required": ["event_id","event_name","source_service","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: ExternalSignalReceived
name: External Signal Received
version: 1.0.0
summary: The first event of every pipeline run. Marks that the job was triggered and establishes the run correlation id.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
This is the very first thing the pipeline emits. It records that a run has been triggered and **generates the `correlation_id` that every later event in the run reuses**. If you are tracing a sync, start here.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.platform` (`dependencies/audit_events.py:36`) |
| `detail-type` | `External Signal Received` (`audit_events.py:47`) |
| `detail.event_name` | `rio.platform.ingestion.external_signal.received` |
| `detail.entity_type` | `external_signal` |
| `detail.action` | `received` |
| Emitted at | `audit_events.py:351` (`emit_external_signal_received`) |
### Tracing a run
Every event from a single pipeline run carries the same `correlation_id`, generated at the start of
the run. Filter the audit trail on that value to see the whole sync in order.
### Who consumes it
Only the Audit Service, via its catch-all rule matching any `source` beginning with `rio`. No service subscribes to it specifically.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "ExternalSignalReceived",
"description": "The first event of every pipeline run. Marks that the job was triggered and establishes the run correlation id. Published on source `rio.platform` with detail-type `External Signal Received`. Shape is the AuditEventDetail dataclass, rio-ingestion-service/jobs/crm-data-sync/dependencies/audit_events.py:100-136. Null values are dropped by to_dict().",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.platform.ingestion.external_signal.received" },
"source_service": { "type": "string", "const": "rio.platform" },
"tenant_id": { "type": ["string", "null"] },
"domain": { "type": "string", "const": "platform" },
"subdomain": { "type": "string", "const": "ingestion" },
"entity_type": { "type": "string", "const": "external_signal" },
"entity_id": { "type": "string" },
"action": { "type": "string", "const": "received" },
"status": { "type": "string", "enum": ["success", "failed", "rejected", "in_progress"] },
"actor_type": { "type": "string", "enum": ["crm", "system", "user"], "default": "crm" },
"actor_id": { "type": "string", "default": "rio-platform-ingestion-service" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": "string", "description": "Generated once per run and stamped on every event from that run - this is how you trace a whole sync." },
"request_id": { "type": "string" },
"before": { "type": ["object", "null"] },
"after": { "type": ["object", "null"] },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": {
"type": ["object", "null"],
"description": "Per-event extras.",
"properties": {
"table_count": { "type": "integer", "description": "Number of tables in the run (28)." },
"failed_count": { "type": "integer" },
"failed_tables": { "type": "array", "items": { "type": "string" } },
"signal_source": { "type": "string" },
"rows_processed": { "type": "integer" }
}
}
},
"required": ["event_id","event_name","source_service","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: GoogleConnectionEstablished
name: Google Connection Established
version: 1.0.0
summary: Published the first time a person successfully connects their Google account. Triggers a backfill of their existing mail.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
This is the starting gun for everything else in the Deal Desk Service.
A user authorises RIO against their Google account through AWS Bedrock AgentCore Identity. Once the
callback succeeds, the service writes the connection to its `oauth_connection` table and publishes
this event. A state machine then goes and fetches the mail that already existed before they
connected — the backfill.
Without this event, a newly connected user would only ever see mail that arrived *after* they
connected.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.activity` (`api/services/eventbridge.py:20`) |
| `detail-type` | `Google Connection Established` (`api/services/eventbridge.py:21`) |
| Published by | `api/services/eventbridge.py:58-70` |
| Emitted at | `api/services/google_connection_service.py:357` |
This event uses its **own compact payload**, not the audit envelope and not the pipeline-alert shape.
### Who consumes it
This service's own email backfill state machine, via
`${DeployPrefix}-email-backfill-on-connect` (`infrastructure/ingestion/template.yaml:525-542`).
The rule uses an `InputTransformer` to map `$.detail.person_internal_id` into the state machine's
`connection_ids` input — so **the `person_internal_id` field is load-bearing**. Renaming it would
break the backfill silently.
The Audit Service also picks it up via
its catch-all rule.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "GoogleConnectionEstablished",
"description": "Published the first time a person successfully connects their Google account. Shape from rio-deal-desk/api/services/eventbridge.py:48-57. This is a compact business payload, not the audit envelope.",
"type": "object",
"properties": {
"event_version": {
"type": "integer",
"const": 1
},
"person_internal_id": {
"type": "string",
"format": "uuid",
"description": "The connecting person. Load-bearing: the EventBridge rule's InputTransformer maps this field into the backfill state machine's connection_ids input."
},
"tenant_id": {
"type": ["string", "null"],
"format": "uuid"
},
"provider": {
"type": "string",
"const": "google"
},
"connection_status": {
"type": "string",
"const": "success"
},
"scopes": {
"type": "array",
"items": { "type": "string" },
"description": "Google OAuth scopes the user granted."
},
"connected_at": {
"type": "string",
"format": "date-time",
"description": "ISO-8601 UTC timestamp of the connection."
},
"occurred_at": {
"type": "string",
"format": "date-time"
}
},
"required": [
"event_version",
"person_internal_id",
"provider",
"connection_status",
"connected_at",
"occurred_at"
]
}
---
id: HierarchyUpdated
name: Hierarchy Updated
version: 1.0.0
summary: Published when the org hierarchy changes. Emitted on two different sources with two different payload shapes.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
The org hierarchy is the manager chain, flattened onto every person as `level_1_id` … `level_10_id`
plus a `hierarchy_path`. When that chain changes, everything downstream that answers "who reports to
this manager" has to be recalculated. This event is the trigger for that work.
**Read this carefully: `Hierarchy Updated` is emitted twice, on two different sources, with two
different payload shapes.** They are not interchangeable.
### Variant A — the audit record
| Field | Value |
|---|---|
| `source` | `rio.core` |
| `detail-type` | `Hierarchy Updated` |
| `detail.event_name` | `rio.core.identity.hierarchy.updated` |
| Payload | `AuditEventDetail` — the standard envelope, see the schema below |
| Emitted at | `api/services/hierarchy_service.py:2983-2994` |
| Purpose | Record that the change happened, for the audit trail |
### Variant B — the recalculation trigger
| Field | Value |
|---|---|
| `source` | `rio.api.hierarchy_change` (`hierarchy_service.py:2077-2080`) |
| `detail-type` | `Hierarchy Updated` (`hierarchy_service.py:2081-2084`) |
| `detail.event_name` | `rio.user.hierarchy.updated` (`hierarchy_service.py:2023-2025`) |
| Payload | a plain dict, **not** the audit envelope — `event_name`, `domain: "identity"`, `version: "v1"`, `actor`, `context`, `metadata`, `data` |
| Emitted at | `api/services/hierarchy_service.py:2050-2101` |
| Purpose | Make the hierarchy Lambda actually do the recalculation |
Variant B's `data` block carries `hierarchy_revision_id`, a list of `updates`, and
`provisioning_source: "MANUAL"`. Entries are batched under a 240 KB ceiling
(`EVENTBRIDGE_ENTRY_SIZE_LIMIT`, `api/core/constants.py:121`), because EventBridge caps entry size.
All three of `source`, `detail-type` and `detail.event_name` are configurable through settings; the
values above are the fallbacks used when nothing is overridden.
## Why there are two variants
Variant B is shaped to match the event that `rio-ingestion-service` publishes when the CRM sync
detects a manager change — see
HierarchyUpdatedByIngestion.
Because both use `detail-type` `Hierarchy Updated` and `detail.event_name`
`rio.user.hierarchy.updated`, a **single** rule catches hierarchy changes from either origin:
```
${DeployPrefix}-hierarchy-updates (infrastructure/lambda/template.yaml:168-179)
source: [rio.glue.crm_sync, rio.api.hierarchy_change]
detail-type: [Hierarchy Updated]
detail.event_name: [rio.user.hierarchy.updated]
```
So a hierarchy change made through the API and one detected by the scheduled CRM sync both land in
the same recalculation Lambda.
## Who consumes it
- **Variant B** → this service's own hierarchy recalculation Lambda, via the rule above.
- **Variant A** → the Audit Service, via
its catch-all rule matching any `source` that begins with `rio`.
### Payload Schema
The schema below documents **Variant A**, the audit envelope. Variant B's shape is described in the
table above and is structurally identical to `HierarchyUpdatedByIngestion`.
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "HierarchyUpdated",
"description": "Variant A audit record for an org-hierarchy change. Envelope published on source `rio.core` with detail-type `Hierarchy Updated`. Shape is the AuditEventDetail Pydantic model, rio-identity-service/api/events/models.py:88-120.",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Auto-generated identifier, format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.core.identity.hierarchy.updated", "description": "Dotted logical name of this event." },
"source_service": { "type": "string", "default": "rio.core", "description": "Service that published the event." },
"tenant_id": { "type": "string", "description": "Tenant the change belongs to." },
"domain": { "type": "string", "default": "core" },
"subdomain": { "type": "string", "default": "identity" },
"entity_type": { "type": "string", "enum": ["user", "role", "hierarchy", "tenant"], "description": "EventEntityType, models.py:15-81." },
"entity_id": { "type": "string", "description": "Identifier of the record that changed." },
"action": { "type": "string", "enum": ["created", "updated", "deleted", "deactivated", "assigned", "removed", "changed"], "description": "EventAction enum." },
"status": { "type": "string", "enum": ["SUCCESS", "FAILED", "REJECTED"], "default": "SUCCESS", "description": "Emitted uppercase here; the audit service lowercases it on ingest." },
"actor_type": { "type": "string", "enum": ["USER", "SYSTEM", "ADMIN", "CRM"], "default": "USER" },
"actor_id": { "type": "string", "description": "Who or what triggered the change." },
"occurred_at": { "type": "string", "format": "date-time", "description": "ISO-8601 timestamp." },
"correlation_id": { "type": ["string", "null"], "description": "Auto-generated corr_* value for tracing across services." },
"request_id": { "type": ["string", "null"], "description": "Auto-generated req_* value for the originating HTTP request." },
"before": { "type": ["object", "null"], "description": "Record state before the change." },
"after": { "type": ["object", "null"], "description": "Record state after the change." },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: HierarchyUpdatedByIngestion
name: HierarchyUpdatedByIngestion
version: 1.0.0
summary: Fired by the ingestion pipeline when manager changes are detected in crm_systemuser_history, to trigger downstream hierarchy recomputation.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Published by `notify_eventbridge_of_manager_changes()` in `main.py`, and only while processing the
`crm_systemuser_history` table. During that table's run the pipeline pre-scans extracted rows and
compares each user's `manager_source_id` against the current RDS state; any user whose manager
changed (excluding protected users) is collected. The changes are sorted top-down by hierarchy
depth and published in batches of **1,000** per `put_events` call.
This is a **business/integration event**, distinct from the audit-event envelope used by the other
events on this service. It uses a **different EventBridge envelope**:
- **DetailType:** `Hierarchy Updated`
- **Event name (in payload):** `rio.user.hierarchy.updated`
- **Source:** `rio.glue.crm_sync` (note: different from the `rio.platform` audit source)
- **Bus:** `EVENT_BUS_NAME` (`{env}-rio-events`), falling back to `default`
- **Batch size:** 1,000 updates per event (`EB_BATCH_SIZE`)
### Downstream
The event is consumed by the
Identity & Hierarchy Service,
whose `${DeployPrefix}-hierarchy-updates` rule
(`rio-identity-service/infrastructure/lambda/template.yaml:168-179`) matches:
```
source: [rio.glue.crm_sync, rio.api.hierarchy_change]
detail-type: [Hierarchy Updated]
detail.event_name: [rio.user.hierarchy.updated]
```
The target Lambda recomputes each affected user's `hierarchy_path` and `level_1_id` … `level_10_id`.
Because that rule also matches `rio.api.hierarchy_change`, the identity service's own
Hierarchy Updated (Variant B) lands in
the same Lambda. A hierarchy change made through the API and one detected by this CRM sync are
handled by identical code — which is why this event deliberately copies that payload shape rather
than using this service's usual audit envelope.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "HierarchyUpdatedByIngestion",
"description": "Fired by the CRM sync when manager changes are detected in crm_systemuser_history. Shape from rio-ingestion-service/jobs/crm-data-sync/main.py:928-936. This is a business/integration event and does NOT use the rio.platform audit envelope used by this service's other events.",
"type": "object",
"properties": {
"event_name": {
"type": "string",
"const": "rio.user.hierarchy.updated",
"description": "Load-bearing. The identity service's rule matches on this exact value."
},
"domain": { "type": "string", "const": "user" },
"version": { "type": "string", "const": "v1" },
"actor": {
"type": "object",
"description": "Who caused the change. Always the sync job itself.",
"properties": {
"type": { "type": "string", "const": "system" },
"id": { "type": "string", "const": "glue_crm_sync_job" }
},
"required": ["type", "id"]
},
"context": {
"type": "object",
"properties": {
"tenant_id": { "type": ["string", "null"] },
"workspace_id": { "type": "null", "description": "Always null in this publisher." },
"request_id": { "type": ["string", "null"] }
}
},
"metadata": {
"type": "object",
"properties": {
"created_at": { "type": "string", "format": "date-time" },
"created_by": { "type": "string", "const": "system" }
}
},
"data": {
"type": "object",
"description": "The manager changes themselves, batched up to 1000 per event.",
"properties": {
"updates": {
"type": "array",
"maxItems": 1000,
"description": "Sorted top-down by hierarchy depth so parents are reassigned before their reports.",
"items": {
"type": "object",
"properties": {
"source_person_id": { "type": "string", "description": "The person whose manager changed." },
"new_manager_id": { "type": "string", "description": "Their new manager's source id." }
},
"required": ["source_person_id", "new_manager_id"]
}
},
"provisioning_source": {
"type": "string",
"description": "Where the person record came from. Users marked MANUAL are protected and excluded from CRM-driven changes."
}
},
"required": ["updates"]
}
},
"required": ["event_name", "domain", "version", "actor", "context", "metadata", "data"]
}
---
id: NotificationSent
name: Notification Sent
version: 1.0.0
summary: Published after a forecast reminder has been delivered. Records the outcome for the audit trail.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Emitted once a forecast reminder has gone out, so there is a durable record that the nudge happened.`n`n> **Two publishers share this detail-type.** The FastAPI app also declares `Notification Sent` on source `rio.notification.api` (`api/events/models.py:37`), but **nothing in `api/` ever emits it** - the constant has no reference outside its own definition. Only the forecast Lambda version is live. If you write a rule for this detail-type, filter on `source` to be sure which one you are getting.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.forecast-notification` |
| `detail-type` | `Notification Sent` |
| `detail.event_name` | `rio.notification.sent` |
| Emitted at | `lambda/src/forecast_notification/service.py:860` |
### Who consumes it
Only the Audit Service, via its catch-all rule.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "NotificationSent",
"description": "Published after a forecast reminder has been delivered. Records the outcome for the audit trail. Published on source `rio.forecast-notification` with detail-type `Notification Sent`.",
"type": "object",
"properties": {
"event_id": { "type": "string" },
"event_name": { "type": "string", "const": "rio.notification.sent" },
"source_service": { "type": "string", "const": "rio.forecast-notification" },
"tenant_id": { "type": "string" },
"domain": { "type": "string", "const": "notification" },
"subdomain": { "type": "string", "const": "alerts" },
"entity_type": { "type": "string", "const": "notification" },
"entity_id": { "type": "string" },
"action": { "type": "string", "const": "created" },
"status": { "type": "string", "const": "success" },
"actor_type": { "type": "string", "const": "system" },
"actor_id": { "type": "string", "const": "system" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": "string" },
"request_id": { "type": "string" },
"after": { "type": "object", "properties": { "status": { "type": "string" } } }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: NotificationSentToAlertChannels
name: Notification Sent To Alert Channels
version: 1.0.0
summary: Published after an operational failure alert has been posted to the alert channels.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Published after an operational failure alert has been posted to the alert channels.
The dispatch Lambda handles operational failure alerts - today, the email sync and email ingestion
failures raised by the Activity Signal
Service. After it has fanned the alert out, it publishes a receipt like this one so
there is a durable record of **who was told, over which channel, and whether it worked**.
This event confirms delivery to the configured alert channels, such as Google Chat.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.platform.notification` (`lambda/src/notification_service/service.py:37`) |
| `detail-type` | `Notification Sent To Alert Channels` (`service.py:332`) |
| `detail.event_name` | `rio.platform.notification.alertchannelsnotified` (`service.py:333`) |
| Published by | `service.py:417-425` |
### What is useful in the payload
Beyond the standard audit envelope this one adds:
- **`severity`** - how serious the underlying alert was.
- **`metadata.recipients`** - the actual addresses or channels contacted.
- **`metadata.sent_count`** / **`metadata.failed_count`** - the delivery tally.
- **`metadata.source_event_id`**, **`source_event_name`**, **`source_detail_type`** - a pointer back
to the failure event that caused this notification, so you can trace cause to effect.
If any delivery failed, `error_code` is set to `NOTIFICATION_DELIVERY_FAILED`.
### Who consumes it
Only the Audit Service, via its
catch-all rule matching any `source` beginning with `rio`.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "NotificationSentToAlertChannels",
"description": "Published after an operational failure alert has been posted to the alert channels. Published on source `rio.platform.notification` with detail-type `Notification Sent To Alert Channels`. Built at rio-platform-notification-service/lambda/src/notification_service/service.py:349-400.",
"type": "object",
"properties": {
"event_id": { "type": "string" },
"event_name": { "type": "string", "const": "rio.platform.notification.alertchannelsnotified" },
"source_service": { "type": "string", "const": "rio.platform.notification" },
"tenant_id": { "type": "string" },
"domain": { "type": "string", "const": "notification" },
"subdomain": { "type": "string", "const": "alerts" },
"entity_type": { "type": "string", "const": "notification" },
"entity_id": { "type": "string" },
"action": { "type": "string" },
"status": { "type": "string", "enum": ["success", "failed"] },
"severity": { "type": "string", "description": "Severity carried over from the originating failure event." },
"event_version": { "type": "integer", "const": 1 },
"actor_type": { "type": "string", "const": "system" },
"actor_id": { "type": "string" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": ["string", "null"] },
"request_id": { "type": ["string", "null"] },
"error_code": { "type": ["string", "null"], "description": "Set to NOTIFICATION_DELIVERY_FAILED when any recipient failed." },
"error_message": { "type": ["string", "null"] },
"metadata": {
"type": "object",
"description": "Delivery detail and a pointer back to the event that triggered this notification.",
"properties": {
"source_event_id": { "type": "string" },
"source_event_name": { "type": "string" },
"source_detail_type": { "type": "string" },
"notification_channel": { "type": "string" },
"recipients": { "type": "array", "items": { "type": "string" } },
"sent_count": { "type": "integer" },
"failed_count": { "type": "integer" }
}
}
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: NotificationSentToTechnicalEmails
name: Notification Sent To Technical Emails
version: 1.0.0
summary: Published after an operational failure alert has been emailed to the technical contact list.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Published after an operational failure alert has been emailed to the technical contact list.
The dispatch Lambda handles operational failure alerts - today, the email sync and email ingestion
failures raised by the Activity Signal
Service. After it has fanned the alert out, it publishes a receipt like this one so
there is a durable record of **who was told, over which channel, and whether it worked**.
This event confirms delivery to SES email to the technical distribution list.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.platform.notification` (`lambda/src/notification_service/service.py:37`) |
| `detail-type` | `Notification Sent To Technical Emails` (`service.py:328`) |
| `detail.event_name` | `rio.platform.notification.technicalemailsnotified` (`service.py:329`) |
| Published by | `service.py:417-425` |
### What is useful in the payload
Beyond the standard audit envelope this one adds:
- **`severity`** - how serious the underlying alert was.
- **`metadata.recipients`** - the actual addresses or channels contacted.
- **`metadata.sent_count`** / **`metadata.failed_count`** - the delivery tally.
- **`metadata.source_event_id`**, **`source_event_name`**, **`source_detail_type`** - a pointer back
to the failure event that caused this notification, so you can trace cause to effect.
If any delivery failed, `error_code` is set to `NOTIFICATION_DELIVERY_FAILED`.
### Who consumes it
Only the Audit Service, via its
catch-all rule matching any `source` beginning with `rio`.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "NotificationSentToTechnicalEmails",
"description": "Published after an operational failure alert has been emailed to the technical contact list. Published on source `rio.platform.notification` with detail-type `Notification Sent To Technical Emails`. Built at rio-platform-notification-service/lambda/src/notification_service/service.py:349-400.",
"type": "object",
"properties": {
"event_id": { "type": "string" },
"event_name": { "type": "string", "const": "rio.platform.notification.technicalemailsnotified" },
"source_service": { "type": "string", "const": "rio.platform.notification" },
"tenant_id": { "type": "string" },
"domain": { "type": "string", "const": "notification" },
"subdomain": { "type": "string", "const": "alerts" },
"entity_type": { "type": "string", "const": "notification" },
"entity_id": { "type": "string" },
"action": { "type": "string" },
"status": { "type": "string", "enum": ["success", "failed"] },
"severity": { "type": "string", "description": "Severity carried over from the originating failure event." },
"event_version": { "type": "integer", "const": 1 },
"actor_type": { "type": "string", "const": "system" },
"actor_id": { "type": "string" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": ["string", "null"] },
"request_id": { "type": ["string", "null"] },
"error_code": { "type": ["string", "null"], "description": "Set to NOTIFICATION_DELIVERY_FAILED when any recipient failed." },
"error_message": { "type": ["string", "null"] },
"metadata": {
"type": "object",
"description": "Delivery detail and a pointer back to the event that triggered this notification.",
"properties": {
"source_event_id": { "type": "string" },
"source_event_name": { "type": "string" },
"source_detail_type": { "type": "string" },
"notification_channel": { "type": "string" },
"recipients": { "type": "array", "items": { "type": "string" } },
"sent_count": { "type": "integer" },
"failed_count": { "type": "integer" }
}
}
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: NotificationUpdated
name: Notification Updated
version: 1.0.0
summary: Published when a user marks an in-app alert as read or unread.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Fired by `PATCH /tenants/{tenant_id}/alerts/{notification_id}` when someone changes an alert's status in the notification centre. It uses the standard audit envelope, with `domain: "notification"` and `subdomain: "alerts"`.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.notification.api` |
| `detail-type` | `Notification Updated` |
| `detail.event_name` | `rio.notification.updated` |
| Emitted at | `api/services/alerts.py:217` |
### Who consumes it
Only the Audit Service, via its catch-all `source` prefix `rio` rule.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "NotificationUpdated",
"description": "Published when a user marks an in-app alert as read or unread. Published on source `rio.notification.api` with detail-type `Notification Updated`.",
"type": "object",
"properties": {
"event_id": { "type": "string" },
"event_name": { "type": "string", "const": "rio.notification.updated" },
"source_service": { "type": "string", "const": "rio.notification.api" },
"tenant_id": { "type": "string" },
"domain": { "type": "string", "const": "notification" },
"subdomain": { "type": "string", "const": "alerts" },
"entity_type": { "type": "string", "const": "notification" },
"entity_id": { "type": "string" },
"action": { "type": "string", "enum": ["created", "updated"] },
"status": { "type": "string", "default": "success" },
"actor_type": { "type": "string", "const": "user" },
"actor_id": { "type": "string" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": "string" },
"request_id": { "type": "string" },
"before": { "type": ["object", "null"] },
"after": { "type": ["object", "null"] },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: PatternSynthesisFailed
name: Pattern Synthesis Failed
version: 1.0.0
summary: Published when the stage that rolls many threads into reusable patterns fails.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Synthesis is the last stage - it aggregates canonical learnings across many threads into reusable patterns with percentile metrics. This event is raised when that fails.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.activity` (`lambdas/_shared/events.py:19`) |
| `detail-type` | `Pattern Synthesis Failed` (`lambdas/_shared/events.py:23`) |
| Emitted at | `lambdas/batch_assembler/handler.py:824` |
### Who consumes it
Nothing subscribes to this event. It is an operational alert that lands in the Audit Service via the catch-all rule, and is otherwise available for a future alerting rule. Unlike the two email **ingestion** failures, it does **not** carry `target_service`, so it does not reach the Notification Service.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "PatternSynthesisFailed",
"description": "Published when the stage that rolls many threads into reusable patterns fails. Pipeline alert shape from rio-deal-desk/lambdas/_shared/events.py:30-45. This is NOT the audit envelope.",
"type": "object",
"properties": {
"event_version": { "type": "integer", "const": 1 },
"run_id": { "type": "string", "description": "Identifies the pipeline run this failure belongs to." },
"stage": { "type": "string", "description": "Pipeline stage name.", "const": "synthesis" },
"job_arn": { "type": ["string", "null"], "description": "ARN of the underlying job, when there is one." },
"error": { "type": "string", "description": "Error text, truncated to 1000 characters." },
"occurred_at": { "type": "string", "format": "date-time" }
},
"required": ["event_version","run_id","stage","error","occurred_at"]
}
---
id: QuotaAssigned
name: Quota Assigned
version: 1.0.0
summary: Published when a manager sets a quota target for someone in their team.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Fired when a quota is created or set through the quota endpoints, all guarded by `require_manage_quota_permission`.`n`n> **Careful:** the bulk-create path at `api/services/quota_service.py:444` passes `detail_type="QuotaAssigned"` without the space. That path emits a **different, invalid** detail-type which the audit service rejects. See the service page for detail.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` (but see the service page - this repo defaults to `rio-commit-events`) |
| `source` | `rio.commit` (`api/events/schemas.py:11`) |
| `detail-type` | `Quota Assigned` (`api/events/schemas.py:16`) |
| `detail.event_name` | `rio.commit.quota.assigned` |
| `detail.entity_type` | `quota` |
| `detail.action` | `assigned` |
| Emitted at | `api/services/quota_service.py:303`, `:539`, `:886` - and, malformed, `:444` |
### What the payload carries
Only the audit envelope. The rich domain fields (amounts, breakdowns, quarter labels) are **dropped** before publishing - see the service page. `before` and `after` are always `null`.
### Who consumes it
Only the Audit Service, through its catch-all rule matching any `source` beginning with `rio`. No service subscribes to it specifically.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "QuotaAssigned",
"description": "Published when a manager sets a quota target for someone in their team. Envelope built by _enrich_event_detail (rio-commit-service/api/events/eventbridge.py:36-96) and published on source `rio.commit` with detail-type `Quota Assigned`.",
"type": "object",
"properties": {
"event_id": { "type": "string", "format": "uuid" },
"event_name": { "type": "string", "const": "rio.commit.quota.assigned" },
"source_service": { "type": "string", "const": "rio.commit" },
"tenant_id": { "type": "string" },
"domain": { "type": "string", "const": "commit" },
"subdomain": { "type": "string", "const": "quota" },
"entity_type": { "type": "string", "const": "quota" },
"entity_id": { "type": "string", "description": "The quota_internal_id." },
"action": { "type": "string", "const": "assigned" },
"status": { "type": "string", "default": "success" },
"actor_type": { "type": "string", "const": "user" },
"actor_id": { "type": "string" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": ["string", "null"], "description": "Taken from EventContextMiddleware contextvars." },
"request_id": { "type": ["string", "null"] },
"before": { "type": "null", "description": "Always null - the domain models never populate it." },
"after": { "type": "null", "description": "Always null - the domain models never populate it." },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: QuotaUpdated
name: Quota Updated
version: 1.0.0
summary: Published when an existing quota amount is changed.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Fired by `PUT /tenants/{tenant_id}/quotas/{quota_id}` when the quota already exists. If it does not exist yet, that same endpoint emits `Quota Assigned` instead. Every change is also written to `fact_quota_audit`.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` (but see the service page - this repo defaults to `rio-commit-events`) |
| `source` | `rio.commit` (`api/events/schemas.py:11`) |
| `detail-type` | `Quota Updated` (`api/events/schemas.py:17`) |
| `detail.event_name` | `rio.commit.quota.updated` |
| `detail.entity_type` | `quota` |
| `detail.action` | `updated` |
| Emitted at | `api/services/quota_service.py:907-912` |
### What the payload carries
Only the audit envelope. The rich domain fields (amounts, breakdowns, quarter labels) are **dropped** before publishing - see the service page. `before` and `after` are always `null`.
### Who consumes it
Only the Audit Service, through its catch-all rule matching any `source` beginning with `rio`. No service subscribes to it specifically.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "QuotaUpdated",
"description": "Published when an existing quota amount is changed. Envelope built by _enrich_event_detail (rio-commit-service/api/events/eventbridge.py:36-96) and published on source `rio.commit` with detail-type `Quota Updated`.",
"type": "object",
"properties": {
"event_id": { "type": "string", "format": "uuid" },
"event_name": { "type": "string", "const": "rio.commit.quota.updated" },
"source_service": { "type": "string", "const": "rio.commit" },
"tenant_id": { "type": "string" },
"domain": { "type": "string", "const": "commit" },
"subdomain": { "type": "string", "const": "quota" },
"entity_type": { "type": "string", "const": "quota" },
"entity_id": { "type": "string", "description": "The quota_internal_id." },
"action": { "type": "string", "const": "updated" },
"status": { "type": "string", "default": "success" },
"actor_type": { "type": "string", "const": "user" },
"actor_id": { "type": "string" },
"occurred_at": { "type": "string", "format": "date-time" },
"correlation_id": { "type": ["string", "null"], "description": "Taken from EventContextMiddleware contextvars." },
"request_id": { "type": ["string", "null"] },
"before": { "type": "null", "description": "Always null - the domain models never populate it." },
"after": { "type": "null", "description": "Always null - the domain models never populate it." },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: TenantCreated
name: Tenant Created
version: 1.0.0
summary: Published when a new tenant is onboarded. Triggers the tenant bootstrap Lambda.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
This is the one identity event with a **dedicated consumer inside this service**. Creating a tenant through `POST /tenants` emits it, and the ``${DeployPrefix}-tenant-setup`` rule (`infrastructure/lambda/template.yaml:210-222`) routes it to a Lambda that performs first-time tenant setup.`n`nThe rule matches on all three of ``source``, ``detail-type`` **and** ``detail.event_name``, so all three must be correct for onboarding to fire.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.core` |
| `detail-type` | `Tenant Created` |
| `detail.event_name` | `rio.core.identity.tenant.created` |
| `detail.entity_type` | `tenant` |
| `detail.action` | `created` |
| Published by | `api/events/publisher.py:45-53` |
| Emitted at | `api/services/tenants.py:320-322` |
### Who consumes it
Routed to this service's own tenant-setup Lambda by `\-tenant-setup` (`infrastructure/lambda/template.yaml:210-222`), and also picked up by the audit service's catch-all rule.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "TenantCreated",
"description": "Published when a new tenant is onboarded. Triggers the tenant bootstrap Lambda. Envelope published on source `rio.core` with detail-type `Tenant Created`. Shape is the AuditEventDetail Pydantic model, rio-identity-service/api/events/models.py:88-120.",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Auto-generated identifier, format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.core.identity.tenant.created", "description": "Dotted logical name of this event." },
"source_service": { "type": "string", "default": "rio.core", "description": "Service that published the event." },
"tenant_id": { "type": "string", "description": "Tenant the change belongs to." },
"domain": { "type": "string", "default": "core" },
"subdomain": { "type": "string", "default": "identity" },
"entity_type": { "type": "string", "enum": ["user", "role", "hierarchy", "tenant"], "description": "EventEntityType, models.py:15-81." },
"entity_id": { "type": "string", "description": "Identifier of the record that changed." },
"action": { "type": "string", "enum": ["created", "updated", "deleted", "deactivated", "assigned", "removed", "changed"], "description": "EventAction enum." },
"status": { "type": "string", "enum": ["SUCCESS", "FAILED", "REJECTED"], "default": "SUCCESS", "description": "Emitted uppercase here; the audit service lowercases it on ingest." },
"actor_type": { "type": "string", "enum": ["USER", "SYSTEM", "ADMIN", "CRM"], "default": "USER" },
"actor_id": { "type": "string", "description": "Who or what triggered the change." },
"occurred_at": { "type": "string", "format": "date-time", "description": "ISO-8601 timestamp." },
"correlation_id": { "type": ["string", "null"], "description": "Auto-generated corr_* value for tracing across services." },
"request_id": { "type": ["string", "null"], "description": "Auto-generated req_* value for the originating HTTP request." },
"before": { "type": ["object", "null"], "description": "Record state before the change." },
"after": { "type": ["object", "null"], "description": "Record state after the change." },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: UserCreated
name: User Created
version: 1.0.0
summary: Published when a new person record is created in a tenant.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Fired once a new person has been written to `dim_person`. Carries the created record in `after`, so downstream consumers do not need to call back for it.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.core` |
| `detail-type` | `User Created` |
| `detail.event_name` | `rio.core.identity.user.created` |
| `detail.entity_type` | `user` |
| `detail.action` | `created` |
| Published by | `api/events/publisher.py:45-53` |
| Emitted at | `api/services/users.py:1876-1878` |
### Who consumes it
Only the Audit Service, via its catch-all rule that matches any `source` beginning with `rio`. No other service subscribes to it today.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "UserCreated",
"description": "Published when a new person record is created in a tenant. Envelope published on source `rio.core` with detail-type `User Created`. Shape is the AuditEventDetail Pydantic model, rio-identity-service/api/events/models.py:88-120.",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Auto-generated identifier, format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.core.identity.user.created", "description": "Dotted logical name of this event." },
"source_service": { "type": "string", "default": "rio.core", "description": "Service that published the event." },
"tenant_id": { "type": "string", "description": "Tenant the change belongs to." },
"domain": { "type": "string", "default": "core" },
"subdomain": { "type": "string", "default": "identity" },
"entity_type": { "type": "string", "enum": ["user", "role", "hierarchy", "tenant"], "description": "EventEntityType, models.py:15-81." },
"entity_id": { "type": "string", "description": "Identifier of the record that changed." },
"action": { "type": "string", "enum": ["created", "updated", "deleted", "deactivated", "assigned", "removed", "changed"], "description": "EventAction enum." },
"status": { "type": "string", "enum": ["SUCCESS", "FAILED", "REJECTED"], "default": "SUCCESS", "description": "Emitted uppercase here; the audit service lowercases it on ingest." },
"actor_type": { "type": "string", "enum": ["USER", "SYSTEM", "ADMIN", "CRM"], "default": "USER" },
"actor_id": { "type": "string", "description": "Who or what triggered the change." },
"occurred_at": { "type": "string", "format": "date-time", "description": "ISO-8601 timestamp." },
"correlation_id": { "type": ["string", "null"], "description": "Auto-generated corr_* value for tracing across services." },
"request_id": { "type": ["string", "null"], "description": "Auto-generated req_* value for the originating HTTP request." },
"before": { "type": ["object", "null"], "description": "Record state before the change." },
"after": { "type": ["object", "null"], "description": "Record state after the change." },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: UserDeactivated
name: User Deactivated
version: 1.0.0
summary: Published when a person is deactivated (soft-deleted) rather than removed.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
People are never hard-deleted. A `DELETE` on the user endpoint sets the record inactive and emits this event, so history and audit trails stay intact.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.core` |
| `detail-type` | `User Deactivated` |
| `detail.event_name` | `rio.core.identity.user.deactivated` |
| `detail.entity_type` | `user` |
| `detail.action` | `deactivated` |
| Published by | `api/events/publisher.py:45-53` |
| Emitted at | `api/services/users.py:2186-2188` |
### Who consumes it
Only the Audit Service, via its catch-all rule that matches any `source` beginning with `rio`. No other service subscribes to it today.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "UserDeactivated",
"description": "Published when a person is deactivated (soft-deleted) rather than removed. Envelope published on source `rio.core` with detail-type `User Deactivated`. Shape is the AuditEventDetail Pydantic model, rio-identity-service/api/events/models.py:88-120.",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Auto-generated identifier, format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.core.identity.user.deactivated", "description": "Dotted logical name of this event." },
"source_service": { "type": "string", "default": "rio.core", "description": "Service that published the event." },
"tenant_id": { "type": "string", "description": "Tenant the change belongs to." },
"domain": { "type": "string", "default": "core" },
"subdomain": { "type": "string", "default": "identity" },
"entity_type": { "type": "string", "enum": ["user", "role", "hierarchy", "tenant"], "description": "EventEntityType, models.py:15-81." },
"entity_id": { "type": "string", "description": "Identifier of the record that changed." },
"action": { "type": "string", "enum": ["created", "updated", "deleted", "deactivated", "assigned", "removed", "changed"], "description": "EventAction enum." },
"status": { "type": "string", "enum": ["SUCCESS", "FAILED", "REJECTED"], "default": "SUCCESS", "description": "Emitted uppercase here; the audit service lowercases it on ingest." },
"actor_type": { "type": "string", "enum": ["USER", "SYSTEM", "ADMIN", "CRM"], "default": "USER" },
"actor_id": { "type": "string", "description": "Who or what triggered the change." },
"occurred_at": { "type": "string", "format": "date-time", "description": "ISO-8601 timestamp." },
"correlation_id": { "type": ["string", "null"], "description": "Auto-generated corr_* value for tracing across services." },
"request_id": { "type": ["string", "null"], "description": "Auto-generated req_* value for the originating HTTP request." },
"before": { "type": ["object", "null"], "description": "Record state before the change." },
"after": { "type": ["object", "null"], "description": "Record state after the change." },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: UserRoleAssigned
name: User Role Assigned
version: 1.0.0
summary: Published when a role is created and assigned within a tenant.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Emitted on role creation. Note the mismatch worth knowing about: the detail-type says *Assigned* but the ``event_name`` says ``role.created`` — they refer to the same operation.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.core` |
| `detail-type` | `User Role Assigned` |
| `detail.event_name` | `rio.core.identity.role.created` |
| `detail.entity_type` | `role` |
| `detail.action` | `created` |
| Published by | `api/events/publisher.py:45-53` |
| Emitted at | `api/services/tenant_role.py:417-419` |
### Who consumes it
Only the Audit Service, via its catch-all rule that matches any `source` beginning with `rio`. No other service subscribes to it today.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "UserRoleAssigned",
"description": "Published when a role is created and assigned within a tenant. Envelope published on source `rio.core` with detail-type `User Role Assigned`. Shape is the AuditEventDetail Pydantic model, rio-identity-service/api/events/models.py:88-120.",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Auto-generated identifier, format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.core.identity.role.created", "description": "Dotted logical name of this event." },
"source_service": { "type": "string", "default": "rio.core", "description": "Service that published the event." },
"tenant_id": { "type": "string", "description": "Tenant the change belongs to." },
"domain": { "type": "string", "default": "core" },
"subdomain": { "type": "string", "default": "identity" },
"entity_type": { "type": "string", "enum": ["user", "role", "hierarchy", "tenant"], "description": "EventEntityType, models.py:15-81." },
"entity_id": { "type": "string", "description": "Identifier of the record that changed." },
"action": { "type": "string", "enum": ["created", "updated", "deleted", "deactivated", "assigned", "removed", "changed"], "description": "EventAction enum." },
"status": { "type": "string", "enum": ["SUCCESS", "FAILED", "REJECTED"], "default": "SUCCESS", "description": "Emitted uppercase here; the audit service lowercases it on ingest." },
"actor_type": { "type": "string", "enum": ["USER", "SYSTEM", "ADMIN", "CRM"], "default": "USER" },
"actor_id": { "type": "string", "description": "Who or what triggered the change." },
"occurred_at": { "type": "string", "format": "date-time", "description": "ISO-8601 timestamp." },
"correlation_id": { "type": ["string", "null"], "description": "Auto-generated corr_* value for tracing across services." },
"request_id": { "type": ["string", "null"], "description": "Auto-generated req_* value for the originating HTTP request." },
"before": { "type": ["object", "null"], "description": "Record state before the change." },
"after": { "type": ["object", "null"], "description": "Record state after the change." },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: UserRoleRemoved
name: User Role Removed
version: 1.0.0
summary: Published when a role is soft-deleted from a tenant.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Emitted when a role is removed. As with people, the removal is a soft delete.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.core` |
| `detail-type` | `User Role Removed` |
| `detail.event_name` | `rio.core.identity.role.deleted` |
| `detail.entity_type` | `role` |
| `detail.action` | `deleted` |
| Published by | `api/events/publisher.py:45-53` |
| Emitted at | `api/services/tenant_role.py:697-699` |
### Who consumes it
Only the Audit Service, via its catch-all rule that matches any `source` beginning with `rio`. No other service subscribes to it today.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "UserRoleRemoved",
"description": "Published when a role is soft-deleted from a tenant. Envelope published on source `rio.core` with detail-type `User Role Removed`. Shape is the AuditEventDetail Pydantic model, rio-identity-service/api/events/models.py:88-120.",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Auto-generated identifier, format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.core.identity.role.deleted", "description": "Dotted logical name of this event." },
"source_service": { "type": "string", "default": "rio.core", "description": "Service that published the event." },
"tenant_id": { "type": "string", "description": "Tenant the change belongs to." },
"domain": { "type": "string", "default": "core" },
"subdomain": { "type": "string", "default": "identity" },
"entity_type": { "type": "string", "enum": ["user", "role", "hierarchy", "tenant"], "description": "EventEntityType, models.py:15-81." },
"entity_id": { "type": "string", "description": "Identifier of the record that changed." },
"action": { "type": "string", "enum": ["created", "updated", "deleted", "deactivated", "assigned", "removed", "changed"], "description": "EventAction enum." },
"status": { "type": "string", "enum": ["SUCCESS", "FAILED", "REJECTED"], "default": "SUCCESS", "description": "Emitted uppercase here; the audit service lowercases it on ingest." },
"actor_type": { "type": "string", "enum": ["USER", "SYSTEM", "ADMIN", "CRM"], "default": "USER" },
"actor_id": { "type": "string", "description": "Who or what triggered the change." },
"occurred_at": { "type": "string", "format": "date-time", "description": "ISO-8601 timestamp." },
"correlation_id": { "type": ["string", "null"], "description": "Auto-generated corr_* value for tracing across services." },
"request_id": { "type": ["string", "null"], "description": "Auto-generated req_* value for the originating HTTP request." },
"before": { "type": ["object", "null"], "description": "Record state before the change." },
"after": { "type": ["object", "null"], "description": "Record state after the change." },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: UserUpdated
name: User Updated
version: 1.0.0
summary: Published when a person record or a role definition is changed.
owners:
- revenue-intelligence
schemaPath: schema.json
---
import Footer from '@catalog/components/footer.astro';
## Event Overview
Two different code paths share this one detail-type. Updating a **person** emits it with `event_name` `rio.core.identity.user.updated` (users.py:2086-2088). Updating a **role definition** also emits `User Updated`, but with `event_name` `rio.core.identity.role.updated` ( enant_role.py:600-602).
If you are writing a rule that only cares about people, filter on `detail.event_name` rather than on the detail-type alone.
### Envelope
| Field | Value |
|---|---|
| Bus | `{env}-rio-events` |
| `source` | `rio.core` |
| `detail-type` | `User Updated` |
| `detail.event_name` | `rio.core.identity.user.updated` |
| `detail.entity_type` | `user` |
| `detail.action` | `updated` |
| Published by | `api/events/publisher.py:45-53` |
| Emitted at | `api/services/users.py:2086-2088` and `api/services/tenant_role.py:600-602` |
### Who consumes it
Only the Audit Service, via its catch-all rule that matches any `source` beginning with `rio`. No other service subscribes to it today.
### Payload Schema
## Raw Schema:schema.json
{
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "UserUpdated",
"description": "Published when a person record or a role definition is changed. Envelope published on source `rio.core` with detail-type `User Updated`. Shape is the AuditEventDetail Pydantic model, rio-identity-service/api/events/models.py:88-120.",
"type": "object",
"properties": {
"event_id": { "type": "string", "description": "Auto-generated identifier, format evt_{12 hex chars}." },
"event_name": { "type": "string", "const": "rio.core.identity.user.updated", "description": "Dotted logical name of this event." },
"source_service": { "type": "string", "default": "rio.core", "description": "Service that published the event." },
"tenant_id": { "type": "string", "description": "Tenant the change belongs to." },
"domain": { "type": "string", "default": "core" },
"subdomain": { "type": "string", "default": "identity" },
"entity_type": { "type": "string", "enum": ["user", "role", "hierarchy", "tenant"], "description": "EventEntityType, models.py:15-81." },
"entity_id": { "type": "string", "description": "Identifier of the record that changed." },
"action": { "type": "string", "enum": ["created", "updated", "deleted", "deactivated", "assigned", "removed", "changed"], "description": "EventAction enum." },
"status": { "type": "string", "enum": ["SUCCESS", "FAILED", "REJECTED"], "default": "SUCCESS", "description": "Emitted uppercase here; the audit service lowercases it on ingest." },
"actor_type": { "type": "string", "enum": ["USER", "SYSTEM", "ADMIN", "CRM"], "default": "USER" },
"actor_id": { "type": "string", "description": "Who or what triggered the change." },
"occurred_at": { "type": "string", "format": "date-time", "description": "ISO-8601 timestamp." },
"correlation_id": { "type": ["string", "null"], "description": "Auto-generated corr_* value for tracing across services." },
"request_id": { "type": ["string", "null"], "description": "Auto-generated req_* value for the originating HTTP request." },
"before": { "type": ["object", "null"], "description": "Record state before the change." },
"after": { "type": ["object", "null"], "description": "Record state after the change." },
"error_code": { "type": ["string", "null"] },
"error_message": { "type": ["string", "null"] },
"metadata": { "type": ["object", "null"] }
},
"required": ["event_id","event_name","source_service","tenant_id","domain","entity_type","entity_id","action","status","actor_type","actor_id","occurred_at"]
}
---
id: AnalyticsSemanticLayer
name: Analytics Semantic Layer (Cube)
version: 1.0.0
summary: The Analytics Semantic Layer provides a consistent interface between RIO's ClickHouse analytics database and downstream consumers such as dashboards, applications, and AI assistants.It defines 35 analytical cubes covering opportunities, quotes, orders, accounts, products, territories, contracts, entitlements, leads, activities, and related business data. Each cube provides predefined measures, dimensions, and segments, ensuring that commonly used business metrics are calculated consistently across consumers.
repository:
language: YAML
url: 'https://github01.hclpnp.com/CE-Nova/rio-cube-semantic-layer'
---
import Footer from '@catalog/components/footer.astro';
## What this service does
RIO stores its analytical data in ClickHouse, where raw tables contain the underlying business data. Querying these tables directly requires consumers to understand table relationships, joins, historical records, and business definitions.
The Analytics Semantic Layer addresses this by using Cube as a semantic modeling layer over ClickHouse.
Cube provides:
- **Measures** — predefined calculations such as counts, sums, and amounts.
- **Dimensions** — fields used to group, filter, and analyze data.
- **Segments** — reusable filters for common query scenarios.
- **Consistent business definitions** — ensures the same metric produces the same result across consumers.
This allows dashboards and AI-powered applications to query standardized business concepts instead of working directly with raw ClickHouse tables.
## The 35 cubes
The cubes cover opportunities, quotes, orders, products, accounts, territories, contracts,
entitlements, leads, activities and more. They split into two patterns:
**Dimension cubes** represent the current state of a business object (e.g. the current details of an
account or product). Examples: dim_opportunity, dim_account, dim_product, dim_territory,
dim_contract, dim_campaign.
**Fact cubes** store historical snapshots — one row per object per point in time. Examples:
fact_opportunity_history, fact_quote, fact_sales_order, fact_quote_detail_history,
fact_salesorder_detail_history, fact_bpf_history, fact_quota_history.
There are also two special cubes:
- agg_product_kpi — pre-aggregated product KPIs built from quote and order line items, used for product-first dashboards.
- bridge_product_account — links products to accounts with lifetime totals (e.g. total spend, quote count).
## The full list of deployed cubes:
| Group | Cubes |
|---|---|
| **Opportunities** | dim_opportunity, fact_opportunity_history, fact_opportunity_product, fact_opportunity_stage_history |
| **Quotes & Orders** | fact_quote, fact_quote_detail_history, fact_sales_order, fact_salesorder_detail_history |
| **Accounts** | dim_account, dim_account_history, fact_account_team_history |
| **Products** | dim_product, dim_product_history, agg_product_kpi, bridge_product_account |
| **People & Territories** | dim_person_history, dim_territory, dim_territory_history, dim_user_group_history, fact_deal_team_history |
| **Tenants** | dim_tenant_history, dim_tenant_role_history, dim_tenant_status_history |
| **Contracts & Entitlements** | dim_contract, dim_contract_history, fact_entitlement_header, fact_entitlement_header_history, fact_entitlement_line, fact_entitlement_line_history |
| **Other** | dim_campaign, fact_activity_log, fact_bpf_history, fact_lead_snapshot, fact_quota_history, fact_quota_audit_history |
## MCP server for AI assistants
The MCP server is a lightweight API that allows AI assistants (such as Claude or other large language model tools) to interact with the semantic layer programmatically.
The MCP server provides the following tools:
| Tool | What it does |
|---|---|
| **List tables** | Returns the names and descriptions of all available cubes |
| **Get table details** | Returns all measures, dimensions, and segments for specific cubes |
| **Run a query** | Sends a query to Cube and returns the results |
| **Get analyst instructions** | Returns guidelines that tell the AI how to correctly query RIO data |
| **Get my details** | Returns the authenticated user's identity (tenant, role, person ID) |
| **Search knowledge base** | Searches emails, meeting transcripts, and calendar notes stored in AWS Bedrock (separate from the analytical cubes) |
---
id: AppLayerService
name: Application Layer Service
version: 1.0.0
summary: The single API Gateway front door. Validates Cognito JWTs with a Lambda authorizer and proxies 41 routes over a VPC Link to the backend services.
owners:
- revenue-intelligence
repository:
language: Python
url: 'https://github.com/rio/rio-app-layer'
specifications:
- type: openapi
path: openapi.yaml
name: RIO Application Layer API
---
import Footer from '@catalog/components/footer.astro';
## Service Overview
Every request into RIO comes through here. The Application Layer is an **AWS API Gateway** definition
plus a **Cognito JWT Lambda authorizer** — it holds no business logic of its own.
Its job is:
1. Terminate the public HTTPS endpoint.
2. Validate the caller's Cognito token and reject anything unsigned or expired.
3. Forward the request over a VPC Link to whichever backend service owns that path.
`rio-ui` confirms this design from the other side: the frontend has **one** base URL
(`VITE_API_BASE_URL`) and never calls a service host directly.
## It uses no events at all
There is **no** `put_events` call, no EventBridge rule, no SQS queue and no event source mapping
anywhere in the repository. That is why this page shows no messages — correctly, not by omission.
The four resources it declares are `AuthorizerFunction`, `ApiGateway`,
`AuthorizerInvokePermission` and `ApiBasePathMapping` (`infrastructure/template.yaml:172-243`).
## Routing map
Paths are `http_proxy` integrations targeting `http://${stageVariables.Endpoint}/...`. The backend
endpoints are CloudFormation parameters (`infrastructure/template.yaml:79-101`):
| Path prefix | Routed to | Catalog service |
|---|---|---|
| `/tenants`, `/tenants/{id}`, `/users/tenants`, `.../users`, `.../roles`, `.../user-groups`, `.../tags`, `.../hierarchy`, `.../regions`, `.../timezones` | `IdentityEndpoint` | Identity & Hierarchy |
| `.../opportunities*`, `.../products/discount-benchmarks` | `OpportunityEndpoint` | Opportunity |
| `.../forecast*`, `.../quotas*` | `CommitEndpoint` | Commit |
| `.../audits` | `AuditServiceEndpoint` | Audit |
| `.../alerts*` | `PlatformNotificationEndpoint` | Notification |
| `/activity/*` | `ActivityEndpoint` | Activity Signal |
Note the path rewrite on the last row: the gateway exposes
`/activity/dealdesk/opportunities/{id}/email-summary` and forwards it to the activity service's own
`/activity/opportunities/{id}/email-summary`.
## Authentication
A Lambda authorizer (`lambda/authorizer/index.py`) downloads the Cognito JWKS, verifies the token's
signature, issuer, expiry and audience, then returns an IAM Allow policy and injects the caller's
`sub` and `email` into the request context. Configuration comes from the `CognitoUserPoolId`,
`CognitoRegion` and `AppClientId` parameters (`infrastructure/template.yaml:130-143`).
Downstream services then re-check identity locally against the `person` table rather than calling
back to the identity service.
## ⚠️ The application code is not in this repository
Worth flagging for anyone opening this repo expecting a FastAPI app:
- `pyproject.toml` declares `packages = ["src/rio_app_backend"]`, but **no `src/` directory is
committed**, and `src/` is not in `.gitignore`.
- `tests/conftest.py` imports `from app.main import app`, and **no `app/` directory is committed**.
- `alembic/versions/` holds one scaffold migration creating an `items` table (`id`, `name`,
`description`, timestamps) — placeholder, not domain data.
- `database/postgresql/what_if.sql` defines a What-If engine schema (`conversations`,
`conversation_messages`, `simulation_history`, `agent_feedback`, `query_cache`, `dashboards`,
`saved_charts`, `conversation_states`) with no application code to use it.
So the README's description of a "production-ready FastAPI application" does not match what is
committed. What **is** here — and what is deployed — is the gateway.
## API specification
The attached `openapi.yaml` defines 41 paths. See the
[API Reference](/docs/api-and-sdk) for the endpoint groups broken down by owning service, or use the
interactive explorer linked in this page's sidebar.
## Raw Schema:openapi.yaml
openapi: 3.0.3
info:
title: RIO Application Layer API
description: RIO Backend API for Opportunities, Forecast, and Quota services
version: 1.0.0
contact:
name: RIO Team
servers:
- url: https://api.example.com/app-layer
description: Production API Gateway
paths:
/health:
get:
security: []
summary: Health check endpoint
tags:
- health
operationId: healthCheck
responses:
"200":
description: Service is healthy
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
status:
type: string
database:
type: string
/users/tenants:
get:
security:
- CognitoAuthorizer: []
summary: Cross-tenant user lookup
tags:
- users
operationId: getGlobalUserTenants
parameters:
- name: email
in: query
required: true
schema:
type: string
description: Email address to search for
responses:
"200":
description: User tenant associations
content:
application/json:
schema:
type: object
/tenants/{tenant_id}/opportunities:
get:
security:
- CognitoAuthorizer: []
summary: List opportunities for a user
tags:
- opportunities
operationId: listOpportunities
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
description: Unique identifier of the tenant
- name: page
in: query
schema:
type: integer
default: 1
minimum: 1
- name: page_size
in: query
schema:
type: integer
default: 20
minimum: 1
maximum: 100
- name: search
in: query
schema:
type: string
description: Search by opportunity name
- name: territory_name
in: query
schema:
type: array
items:
type: string
description: Filter by territory name (multi-value supported)
- name: standard_opportunity_type
in: query
schema:
type: array
items:
type: string
description: Filter by opportunity type (multi-value supported)
- name: standard_sales_stage
in: query
schema:
type: array
items:
type: string
description: Filter by sales stage (multi-value supported)
- name: standard_forecast_category
in: query
schema:
type: array
items:
type: string
description: Filter by forecast category (multi-value supported)
- name: is_won
in: query
schema:
type: boolean
description: Filter by won status
- name: is_closed
in: query
schema:
type: boolean
description: Filter by closed status
- name: opportunity_id
in: query
schema:
type: array
items:
type: string
format: uuid
description: Filter by specific opportunity internal IDs (multi-value supported)
- name: userid
in: query
schema:
type: string
format: uuid
description: Filter opportunities to show only those belonging to a specific user (requires authorization)
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
description: Authenticated user ID
responses:
"200":
description: Paginated list of opportunities
content:
application/json:
schema:
$ref: "#/components/schemas/WrappedOpportunityListResponse"
/tenants/{tenant_id}/opportunities/view-all:
get:
security:
- CognitoAuthorizer: []
summary: View all opportunities with statistics
tags:
- opportunities
operationId: viewAllOpportunities
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
description: Unique identifier of the tenant
- name: page
in: query
schema:
type: integer
default: 1
minimum: 1
- name: page_size
in: query
schema:
type: integer
default: 20
minimum: 1
maximum: 100
- name: search
in: query
schema:
type: string
description: Search by opportunity name
- name: source_sales_stage
in: query
schema:
type: array
items:
type: string
description: Filter by source sales stage (multi-value supported)
- name: territory_name
in: query
schema:
type: array
items:
type: string
description: Filter by region / territory (multi-value supported)
- name: product_family
in: query
schema:
type: array
items:
type: string
description: Filter by product family IDs (multi-value supported)
- name: is_closed
in: query
schema:
type: boolean
description: Filter by closed status
- name: is_won
in: query
schema:
type: boolean
description: Filter by won status
- name: standard_forecast_category
in: query
schema:
type: array
items:
type: string
description: Filter by forecast category (multi-value supported)
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
description: Authenticated user ID
responses:
"200":
description: View all opportunities with statistics response
content:
application/json:
schema:
$ref: "#/components/schemas/WrappedOpportunityListResponse"
/tenants/{tenant_id}/opportunities/products:
get:
security:
- CognitoAuthorizer: []
summary: List all distinct product families
description: Get all distinct product families with pagination and optional status filtering.
tags:
- opportunities
operationId: listProductFamilies
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
description: Unique identifier of the tenant
- name: page
in: query
schema:
type: integer
default: 1
minimum: 1
- name: page_size
in: query
schema:
type: integer
default: 20
minimum: 1
maximum: 100
- name: status
in: query
schema:
type: array
items:
type: integer
description: Filter by status (e.g. 0, 1, 2)
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
description: Authenticated user ID
responses:
"200":
description: Paginated list of product families
content:
application/json:
schema:
$ref: "#/components/schemas/ProductFamilyListResponse"
/tenants/{tenant_id}/opportunities/{opportunity_id}:
get:
security:
- CognitoAuthorizer: []
summary: Get opportunity detail by ID
description: Get a single opportunity by ID, subject to visibility rules.
tags:
- opportunities
operationId: getOpportunityDetail
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
description: Unique identifier of the tenant
- name: opportunity_id
in: path
required: true
schema:
type: string
format: uuid
description: Unique identifier of the opportunity
- name: view_all
in: query
schema:
type: boolean
default: false
description: Bypass visibility rules and return the opportunity
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
description: Authenticated user ID
responses:
"200":
description: Opportunity detail
content:
application/json:
schema:
$ref: "#/components/schemas/WrappedOpportunityDetailResponse"
"404":
description: Opportunity not found or insufficient permissions
/tenants/{tenant_id}/opportunities/{opportunity_id}/products:
get:
security:
- CognitoAuthorizer: []
summary: List products for an opportunity
description: Get paginated products associated with an opportunity.
tags:
- opportunities
operationId: getOpportunityProducts
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
description: Unique identifier of the tenant
- name: opportunity_id
in: path
required: true
schema:
type: string
format: uuid
description: Unique identifier of the opportunity
- name: page
in: query
schema:
type: integer
default: 1
minimum: 1
- name: page_size
in: query
schema:
type: integer
default: 20
minimum: 1
maximum: 100
- name: is_active
in: query
schema:
type: boolean
description: Filter products by active status
- name: has_family_amount
in: query
schema:
type: boolean
description: If true, only products with family_total_amount > 0
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
description: Authenticated user ID
responses:
"200":
description: Paginated list of products for the opportunity
content:
application/json:
schema:
$ref: "#/components/schemas/OpportunityProductsListResponse"
/tenants/{tenant_id}/opportunities/{opportunity_id}/quotes:
get:
security:
- CognitoAuthorizer: []
summary: List quotes for an opportunity
description: Get paginated quotes associated with an opportunity.
tags:
- opportunities
operationId: getOpportunityQuotes
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
description: Unique identifier of the tenant
- name: opportunity_id
in: path
required: true
schema:
type: string
format: uuid
description: Unique identifier of the opportunity
- name: page
in: query
schema:
type: integer
default: 1
minimum: 1
- name: page_size
in: query
schema:
type: integer
default: 20
minimum: 1
maximum: 100
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
description: Authenticated user ID
responses:
"200":
description: Paginated list of quotes for the opportunity
content:
application/json:
schema:
$ref: "#/components/schemas/OpportunityQuotesListResponse"
/tenants/{tenant_id}/opportunities/{opportunity_id}/sales-team:
get:
security:
- CognitoAuthorizer: []
summary: List sales team members for an opportunity
description: Get paginated people (deal team members) associated with an opportunity.
tags:
- opportunities
operationId: getOpportunitySalesTeam
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
description: Unique identifier of the tenant
- name: opportunity_id
in: path
required: true
schema:
type: string
format: uuid
description: Unique identifier of the opportunity
- name: page
in: query
schema:
type: integer
default: 1
minimum: 1
- name: page_size
in: query
schema:
type: integer
default: 20
minimum: 1
maximum: 100
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
description: Authenticated user ID
responses:
"200":
description: Paginated list of sales team members for the opportunity
content:
application/json:
schema:
$ref: "#/components/schemas/OpportunitySalesTeamListResponse"
/tenants/{tenant_id}/opportunities/history/snapshots:
get:
security:
- CognitoAuthorizer: []
summary: List historical opportunity snapshots
tags:
- opportunities
operationId: listOpportunityHistory
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
description: Unique identifier of the tenant
- name: page
in: query
schema:
type: integer
default: 1
minimum: 1
- name: page_size
in: query
schema:
type: integer
default: 20
minimum: 1
maximum: 100
- name: search
in: query
schema:
type: string
description: Search by opportunity name
- name: territory_name
in: query
schema:
type: array
items:
type: string
description: Filter by territory name (multi-value supported)
- name: standard_opportunity_type
in: query
schema:
type: array
items:
type: string
description: Filter by opportunity type (multi-value supported)
- name: standard_sales_stage
in: query
schema:
type: array
items:
type: string
description: Filter by sales stage (multi-value supported)
- name: standard_forecast_category
in: query
schema:
type: array
items:
type: string
description: Filter by forecast category (multi-value supported)
- name: is_won
in: query
schema:
type: boolean
description: Filter by won status
- name: is_closed
in: query
schema:
type: boolean
description: Filter by closed status
- name: opportunity_id
in: query
schema:
type: array
items:
type: string
format: uuid
description: Filter by specific opportunity internal IDs (multi-value supported)
- name: snapshot_date
in: query
schema:
type: string
format: date-time
description: Specific snapshot date (ISO format)
- name: quarter
in: query
schema:
type: string
description: Quarter in format 'FY26-Q2', 'FY25-Q1', etc.
- name: start_date
in: query
schema:
type: string
format: date-time
description: Start date for date range filter
- name: end_date
in: query
schema:
type: string
format: date-time
description: End date for date range filter
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
description: Authenticated user ID
responses:
"200":
description: Paginated list of historical opportunity snapshots
content:
application/json:
schema:
$ref: "#/components/schemas/WrappedOpportunityHistoryListResponse"
/tenants/{tenant_id}/forecast/{user_id}:
get:
security:
- CognitoAuthorizer: []
summary: Fetch comprehensive forecast data for a specific user
tags:
- forecast
operationId: getUserForecast
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
- name: user_id
in: path
required: true
schema:
type: string
- name: fiscal_period
in: query
required: true
schema:
type: string
description: "E.g., FY26-Q2"
- name: cadence_label
in: query
required: true
schema:
type: string
description: "E.g., Week 3 or Monthly"
- name: revenue_type
in: query
required: true
schema:
type: string
enum: [New, Renewal]
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
responses:
"200":
description: User forecast detail
content:
application/json:
schema:
$ref: "#/components/schemas/UserForecastResponse"
/tenants/{tenant_id}/forecast/submit:
post:
security:
- CognitoAuthorizer: []
summary: Submit a forecast
tags:
- forecast
operationId: submitForecast
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ForecastSubmissionRequest"
responses:
"200":
description: Forecast submission result
content:
application/json:
schema:
$ref: "#/components/schemas/ForecastActionResponse"
/tenants/{tenant_id}/forecast/adjust:
post:
security:
- CognitoAuthorizer: []
summary: Manager adjustment of a reportee's forecast
tags:
- forecast
operationId: adjustForecast
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/ManagerAdjustmentRequest"
responses:
"200":
description: Forecast adjustment result
content:
application/json:
schema:
$ref: "#/components/schemas/ForecastActionResponse"
/tenants/{tenant_id}/forecast/{user_id}/team-forecasts:
get:
security:
- CognitoAuthorizer: []
summary: Fetch team forecasts for a manager
tags:
- forecast
operationId: getTeamForecasts
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
- name: user_id
in: path
required: true
schema:
type: string
- name: fiscal_period
in: query
required: true
schema:
type: string
description: "E.g., FY26-Q2"
- name: cadence_label
in: query
required: true
schema:
type: string
description: "E.g., Week 3"
- name: revenue_type
in: query
required: true
schema:
type: string
enum: [New, Renewal]
- name: page
in: query
schema:
type: integer
default: 1
minimum: 1
description: Page number (1-indexed)
- name: pagesize
in: query
schema:
type: integer
default: 20
minimum: 1
maximum: 100
description: Items per page
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Team forecasts with pagination and summary
content:
application/json:
schema:
$ref: "#/components/schemas/TeamForecastsResponse"
/tenants/{tenant_id}/forecast/{user_id}/team-overview:
get:
security:
- CognitoAuthorizer: []
summary: Fetch team overview for a manager
tags:
- forecast
operationId: getTeamOverview
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
- name: user_id
in: path
required: true
schema:
type: string
- name: fiscal_period
in: query
required: true
schema:
type: string
description: "E.g., FY26-Q2"
- name: cadence_label
in: query
required: true
schema:
type: string
description: "E.g., Week 3"
- name: revenue_type
in: query
required: false
schema:
type: string
enum: [New, Renewal]
- name: isForecast
in: query
required: false
schema:
type: boolean
default: true
description: Filter forecast-eligible members
- name: page
in: query
schema:
type: integer
default: 1
minimum: 1
description: Page number (1-indexed)
- name: pagesize
in: query
schema:
type: integer
default: 20
minimum: 1
maximum: 100
description: Items per page
- name: X-User-Id
in: header
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Team overview with pagination and summary
content:
application/json:
schema:
$ref: "#/components/schemas/TeamOverviewResponse"
"/tenants/{tenant_id}/quotas":
post:
security:
- CognitoAuthorizer: []
summary: Create Quota
description: Create a quota for a reportee in a specific quarter. Manager (X-User-Id) must have can_manage_quota permission and be the direct manager of the reportee.
tags:
- quotas
operationId: create_quota_tenants__tenant_id__quotas__post
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/QuotaCreateRequest"
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/QuotaResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
get:
security:
- CognitoAuthorizer: []
summary: List Quotas
description: List quotas for reportees under this manager. Optionally filter by reportee_id and/or quarter.
tags:
- quotas
operationId: list_quotas_tenants__tenant_id__quotas__get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: reportee_id
in: query
required: false
schema:
type: string
nullable: true
description: Filter by reportee person_internal_id
title: Reportee Id
description: Filter by reportee person_internal_id
- name: quarter
in: query
required: false
schema:
type: string
nullable: true
description: Filter by quarter e.g. FY2026-Q2
title: Quarter
description: Filter by quarter e.g. FY2026-Q2
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/QuotaListResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/quotas/history:
get:
security:
- CognitoAuthorizer: []
summary: Get Quota History
description: Get historical quota changes and audit trail for a reportee.
tags:
- quotas
operationId: get_quota_history_tenants__tenant_id__quotas_history__get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: reportee_id
in: query
required: true
schema:
type: string
description: Reportee person_internal_id
title: Reportee Id
description: Reportee person_internal_id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/QuotaHistoryResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
"/tenants/{tenant_id}/quotas/{quota_id}":
get:
security:
- CognitoAuthorizer: []
summary: Get Quota
description: Get a specific quota by quota_internal_id.
tags:
- quotas
operationId: get_quota_tenants__tenant_id__quotas__quota_id__get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: quota_id
in: path
required: true
schema:
type: string
title: Quota Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/QuotaResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
put:
security:
- CognitoAuthorizer: []
summary: Update Quota
description: Update the quota amount for an existing quota.
tags:
- quotas
operationId: update_quota_tenants__tenant_id__quotas__quota_id__put
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: quota_id
in: path
required: true
schema:
type: string
title: Quota Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/QuotaUpdateRequest"
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/QuotaResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
delete:
security:
- CognitoAuthorizer: []
summary: Delete Quota
description: Delete a quota record.
tags:
- quotas
operationId: delete_quota_tenants__tenant_id__quotas__quota_id__delete
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: quota_id
in: path
required: true
schema:
type: string
title: Quota Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
type: object
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/audits:
get:
security:
- CognitoAuthorizer: []
summary: Get Audits
description: Query audit trail for RIO platform actions
tags:
- audit
operationId: get_audits_api_v1__tenant_id__audits_get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: domain
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Domain filter
title: Domain
description: Domain filter
- name: day
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Day filter (YYYYMMDD format) - required with domain
title: Day
description: Day filter (YYYYMMDD format) - required with domain
- name: entity_type
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Entity type filter
title: Entity Type
description: Entity type filter
- name: entity_id
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Entity ID filter
title: Entity Id
description: Entity ID filter
- name: actor_id
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Actor ID filter
title: Actor Id
description: Actor ID filter
- name: correlation_id
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Correlation ID filter
title: Correlation Id
description: Correlation ID filter
- name: action
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Action filter (e.g., create, update, delete)
title: Action
description: Action filter (e.g., create, update, delete)
- name: time_from
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Start time filter (ISO 8601)
title: Time From
description: Start time filter (ISO 8601)
- name: time_to
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: End time filter (ISO 8601)
title: Time To
description: End time filter (ISO 8601)
- name: limit
in: query
required: false
schema:
type: integer
maximum: 1000
minimum: 1
description: Max results per page
default: 100
title: Limit
description: Max results per page
- name: last_key
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Pagination token (base64 encoded JSON)
title: Last Key
description: Pagination token (base64 encoded JSON)
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/AuditResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/alerts:
get:
security:
- CognitoAuthorizer: []
summary: List Alerts
description: List alerts for a tenant recipient with filtering and pagination
tags:
- alerts
operationId: list_alerts_tenants__tenant_id__alerts_get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: page
in: query
required: false
schema:
type: integer
minimum: 1
default: 1
title: Page
- name: page_size
in: query
required: false
schema:
type: integer
maximum: 100
minimum: 1
default: 25
title: Page Size
- name: person_id
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Recipient person_internal_id. Defaults to X-User-Id
title: Person Id
description: Recipient person_internal_id. Defaults to X-User-Id
- name: status
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
title: Status
- name: severity
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
title: Severity
- name: notification_type
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
title: Notification Type
- name: source_event_name
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
title: Source Event Name
- name: entity_type
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
title: Entity Type
- name: entity_id
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
title: Entity Id
- name: created_from
in: query
required: false
schema:
anyOf:
- type: string
format: date-time
- type: "null"
description: Filter alerts created on/after this timestamp
title: Created From
description: Filter alerts created on/after this timestamp
- name: created_to
in: query
required: false
schema:
anyOf:
- type: string
format: date-time
- type: "null"
description: Filter alerts created on/before this timestamp
title: Created To
description: Filter alerts created on/before this timestamp
- name: search
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Case-insensitive search across title and message
title: Search
description: Case-insensitive search across title and message
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
type: object
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/alerts/{notification_id}:
patch:
security:
- CognitoAuthorizer: []
summary: Update Alert Status
description: Update an alert status to READ or DELIVERED
tags:
- alerts
operationId: update_alert_status_tenants__tenant_id__alerts__notification_id__patch
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: notification_id
in: path
required: true
schema:
type: string
title: Notification Id
- name: person_id
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Recipient person_internal_id. Defaults to X-User-Id
title: Person Id
description: Recipient person_internal_id. Defaults to X-User-Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
- name: X-Request-Id
in: header
required: false
schema:
anyOf:
- type: string
- type: "null"
title: X-Request-Id
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
status:
type: string
enum:
- READ
- DELIVERED
responses:
"200":
description: Successful Response
content:
application/json:
schema:
type: object
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/hierarchy:
get:
security:
- CognitoAuthorizer: []
tags:
- hierarchy
summary: Get Hierarchy
description: Return all hierarchy revisions for a tenant.
operationId: get_hierarchy_tenants__tenant_id__hierarchy_get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: page
in: query
required: false
schema:
type: integer
minimum: 1
description: Page number
default: 1
title: Page
description: Page number
- name: page_size
in: query
required: false
schema:
type: integer
maximum: 1000
minimum: 1
description: Items per page
default: 50
title: Page Size
description: Items per page
- name: status
in: query
required: false
schema:
anyOf:
- type: string
description: Filter by status (PENDING, APPROVED, REJECTED)
title: Status
description: Filter by status (PENDING, APPROVED, REJECTED)
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/hierarchylistresponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
post:
security:
- CognitoAuthorizer: []
tags:
- hierarchy
summary: Update Hierarchy
description: Create a new hierarchy revision when the incoming payload changes.
operationId: update_hierarchy_tenants__tenant_id__hierarchy_post
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/HierarchyUpdateRequest"
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/HierarchyMutationResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/hierarchy/{hierarchy_id}/nodes/{node_id}:
get:
security:
- CognitoAuthorizer: []
tags:
- hierarchy
summary: Get Hierarchy Node
description: Return a hierarchy node with direct reports.
operationId: get_hierarchy_node_tenants__tenant_id__hierarchy__hierarchy_id__nodes__node_id__get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: hierarchy_id
in: path
required: true
schema:
type: string
title: Hierarchy Id
- name: node_id
in: path
required: true
schema:
type: string
title: Node Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/HierarchyNodeDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/hierarchy/{hierarchy_id}/actions:
post:
security:
- CognitoAuthorizer: []
tags:
- hierarchy
summary: Process Hierarchy Action
description: Approve or reject a hierarchy revision.
operationId: process_hierarchy_action_tenants__tenant_id__hierarchy__hierarchy_id__actions_post
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: hierarchy_id
in: path
required: true
schema:
type: string
title: Hierarchy Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/HierarchyActionRequest"
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/HierarchyMutationResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/users:
get:
security:
- CognitoAuthorizer: []
tags:
- users
summary: Users List
description: >-
List users with context-aware pagination and multi-field filtering.
**Pagination Modes:**
1. **Standard Mode** (default): Uses `page` and `page_size` for RDS offset-based pagination.
2. **Search Mode** (triggered by `search` query): Uses DynamoDB Global Secondary Index for vault searching.
Returns `search_cursor` for cursor-based pagination. `page` is ignored in this mode.
**Filtering:**
Supports exact match filtering on most `dim_person` columns including:
- **Hierarchy**: `node_id`, `excluded_node_id`, `level_1_id` to `level_10_id`.
- **Identity**: `source_system`, `source_person_id`, `manager_source_id`.
- **Attributes**: `person_type`, `is_active`, `region`, `department`.
- **Groups/Roles**: `user_group_id`, `role_id` (standard_role).
operationId: users_list_tenants__tenant_id__users_get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: page
in: query
required: false
schema:
type: integer
minimum: 1
default: 1
title: Page
- name: page_size
in: query
required: false
schema:
type: integer
maximum: 100
minimum: 1
default: 25
title: Page Size
- name: search
in: query
required: false
schema:
anyOf:
- type: string
description: Search across name/email fields
title: Search
description: Search across name/email fields
- name: search_cursor
in: query
required: false
schema:
anyOf:
- type: string
description: DynamoDB cursor for search results
title: Search Cursor
description: DynamoDB cursor for search results
- name: node_id
in: query
required: false
schema:
anyOf:
- type: string
description: Filter by hierarchy node id
title: Node Id
description: Filter by hierarchy node id
- name: excluded_node_id
in: query
required: false
schema:
anyOf:
- type: string
description: Exclude users on this node id
title: Excluded Node Id
description: Exclude users on this node id
- name: role_id
in: query
required: false
schema:
anyOf:
- type: string
description: Filter by standard_role (role code)
title: Role Id
description: Filter by standard_role (role code)
- name: user_group_id
in: query
required: false
schema:
anyOf:
- type: string
description: Filter by user_group_id (UUID)
title: User Group Id
description: Filter by user_group_id (UUID)
- name: source_system
in: query
required: false
schema:
anyOf:
- type: string
title: Source System
- name: source_person_id
in: query
required: false
schema:
anyOf:
- type: string
title: Source Person Id
- name: manager_source_id
in: query
required: false
schema:
anyOf:
- type: string
title: Manager Source Id
- name: source_account_id
in: query
required: false
schema:
anyOf:
- type: string
title: Source Account Id
- name: person_type
in: query
required: false
schema:
anyOf:
- type: string
description: Person type filter
default: Internal Rep
title: Person Type
description: Person type filter
- name: is_active
in: query
required: false
schema:
anyOf:
- type: boolean
title: Is Active
- name: department
in: query
required: false
schema:
anyOf:
- type: string
title: Department
- name: standard_role
in: query
required: false
schema:
anyOf:
- type: string
title: Standard Role
- name: source_role
in: query
required: false
schema:
anyOf:
- type: string
title: Source Role
- name: tenant_role_code
in: query
required: false
schema:
anyOf:
- type: string
title: Tenant Role Code
- name: do_not_email
in: query
required: false
schema:
anyOf:
- type: boolean
title: Do Not Email
- name: do_not_phone
in: query
required: false
schema:
anyOf:
- type: boolean
title: Do Not Phone
- name: region
in: query
required: false
schema:
anyOf:
- type: string
title: Region
- name: assigned_territory_id
in: query
required: false
schema:
anyOf:
- type: string
title: Assigned Territory Id
- name: provisioning_source
in: query
required: false
schema:
anyOf:
- type: string
title: Provisioning Source
- name: workspace_id
in: query
required: false
schema:
anyOf:
- type: string
title: Workspace Id
- name: level_1_id
in: query
required: false
schema:
anyOf:
- type: string
title: Level 1 Id
- name: level_2_id
in: query
required: false
schema:
anyOf:
- type: string
title: Level 2 Id
- name: level_3_id
in: query
required: false
schema:
anyOf:
- type: string
title: Level 3 Id
- name: level_4_id
in: query
required: false
schema:
anyOf:
- type: string
title: Level 4 Id
- name: level_5_id
in: query
required: false
schema:
anyOf:
- type: string
title: Level 5 Id
- name: level_6_id
in: query
required: false
schema:
anyOf:
- type: string
title: Level 6 Id
- name: level_7_id
in: query
required: false
schema:
anyOf:
- type: string
title: Level 7 Id
- name: level_8_id
in: query
required: false
schema:
anyOf:
- type: string
title: Level 8 Id
- name: level_9_id
in: query
required: false
schema:
anyOf:
- type: string
title: Level 9 Id
- name: level_10_id
in: query
required: false
schema:
anyOf:
- type: string
description: Level 10 id
title: Level 10 Id
description: Level 10 id
- name: manager_id
in: query
required: false
schema:
anyOf:
- type: string
format: uuid
title: Manager Id
description: Internal user UUID; returns reportees flattened up to nested_upto depth
- name: nested_upto
in: query
required: false
schema:
type: integer
default: 1
minimum: 1
maximum: 10
title: Nested Upto
description: Depth of reportee traversal under manager_id (default 1 = direct reports)
- name: without_roles
in: query
required: false
schema:
anyOf:
- type: boolean
title: Without Roles
description: When true, only users with no standard_role are returned
- name: without_managers
in: query
required: false
schema:
anyOf:
- type: boolean
title: Without Managers
description: When true, only users with no manager_source_id (or top-of-tree) are returned
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/UsersListResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
post:
security:
- CognitoAuthorizer: []
tags:
- users
summary: Create User
description: Create a manually-provisioned Internal Rep user for a tenant.
operationId: create_user_tenants__tenant_id__users_post
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UserCreateRequest"
responses:
"201":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/UserDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/users/{user_id}:
get:
security:
- CognitoAuthorizer: []
tags:
- users
summary: Users Detail
description: Get a single user with full role details by person_internal_id.
operationId: users_detail_tenants__tenant_id__users__user_id__get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: user_id
in: path
required: true
schema:
type: string
title: User Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/UserDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
patch:
security:
- CognitoAuthorizer: []
tags:
- users
summary: Update User
description: Update profile/role fields of a user. Sets provisioning_source to MANUAL.
operationId: update_user_tenants__tenant_id__users__user_id__patch
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: user_id
in: path
required: true
schema:
type: string
title: User Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UserUpdateRequest"
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/UserDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
delete:
security:
- CognitoAuthorizer: []
tags:
- users
summary: Delete User
description: Soft-delete a user. Only allowed for MANUAL/SYSTEM-provisioned users.
operationId: delete_user_tenants__tenant_id__users__user_id__delete
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: user_id
in: path
required: true
schema:
type: string
title: User Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"204":
description: Successful Response
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/user-groups:
get:
security:
- CognitoAuthorizer: []
tags:
- usergroup
summary: Get User Groups
description: Get all active user groups for a tenant.
operationId: get_user_groups_tenants__tenant_id__user_groups_get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: page
in: query
required: false
schema:
type: integer
minimum: 1
default: 1
title: Page
- name: page_size
in: query
required: false
schema:
type: integer
maximum: 100
minimum: 1
default: 25
title: Page Size
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/UserGroupListResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
post:
security:
- CognitoAuthorizer: []
tags:
- usergroup
summary: Create User Group
description: Create a new user group for a tenant.
operationId: create_user_group_tenants__tenant_id__user_groups_post
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UserGroupCreateRequest"
responses:
"201":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/UserGroupDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/user-groups/{user_group_id}:
get:
security:
- CognitoAuthorizer: []
tags:
- usergroup
summary: Get User Group By Id
description: Get a specific active user group by ID.
operationId: get_user_group_by_id_tenants__tenant_id__user_groups__user_group_id__get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: user_group_id
in: path
required: true
schema:
type: string
title: User Group Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/UserGroupDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
put:
security:
- CognitoAuthorizer: []
tags:
- usergroup
summary: Update User Group
description: Partially update an active user group (description and/or rules).
operationId: update_user_group_tenants__tenant_id__user_groups__user_group_id__put
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: user_group_id
in: path
required: true
schema:
type: string
title: User Group Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/UserGroupUpdateRequest"
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/UserGroupDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
delete:
security:
- CognitoAuthorizer: []
tags:
- usergroup
summary: Delete User Group
description: Soft delete a user group when it has no mapped users.
operationId: delete_user_group_tenants__tenant_id__user_groups__user_group_id__delete
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: user_group_id
in: path
required: true
schema:
type: string
title: User Group Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"204":
description: Successful Response
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/tags:
get:
security:
- CognitoAuthorizer: []
tags:
- tags
summary: Get Tags
description: List all active tags for a tenant (paginated, optional search).
operationId: get_tags_tenants__tenant_id__tags_get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: page
in: query
required: false
schema:
type: integer
minimum: 1
default: 1
title: Page
- name: page_size
in: query
required: false
schema:
type: integer
maximum: 100
minimum: 1
default: 25
title: Page Size
- name: search
in: query
required: false
schema:
anyOf:
- type: string
maxLength: 100
- type: "null"
title: Search
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/TagListResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
post:
security:
- CognitoAuthorizer: []
tags:
- tags
summary: Create Tag
description: Create a new tag for a tenant.
operationId: create_tag_tenants__tenant_id__tags_post
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TagCreateRequest"
responses:
"201":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/TagDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/tags/{tag_id}:
get:
security:
- CognitoAuthorizer: []
tags:
- tags
summary: Get Tag By Id
description: Get a specific active tag by ID.
operationId: get_tag_by_id_tenants__tenant_id__tags__tag_id__get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: tag_id
in: path
required: true
schema:
type: string
title: Tag Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/TagDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
put:
security:
- CognitoAuthorizer: []
tags:
- tags
summary: Update Tag
description: Partially update an active tag (name, description and/or permissions).
operationId: update_tag_tenants__tenant_id__tags__tag_id__put
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: tag_id
in: path
required: true
schema:
type: string
title: Tag Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TagUpdateRequest"
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/TagDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
delete:
security:
- CognitoAuthorizer: []
tags:
- tags
summary: Delete Tag
description: Soft delete a tag (sets is_active = FALSE).
operationId: delete_tag_tenants__tenant_id__tags__tag_id__delete
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: tag_id
in: path
required: true
schema:
type: string
title: Tag Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"204":
description: Successful Response
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/roles:
get:
security:
- CognitoAuthorizer: []
tags:
- roles
summary: List Roles
description: List all active roles for a tenant with optional filters and pagination.
operationId: list_roles_tenants__tenant_id__roles_get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: page
in: query
required: false
schema:
type: integer
minimum: 1
description: Page number
default: 1
title: Page
description: Page number
- name: page_size
in: query
required: false
schema:
type: integer
maximum: 100
minimum: 1
description: Items per page
default: 25
title: Page Size
description: Items per page
- name: role_type
in: query
required: false
schema:
anyOf:
- type: string
description: Filter by role_type (leaf, manager, executive, overlay)
title: Role Type
description: Filter by role_type (leaf, manager, executive, overlay)
- name: workspace_id
in: query
required: false
schema:
anyOf:
- type: string
description: Filter by workspace_id (UUID)
title: Workspace Id
description: Filter by workspace_id (UUID)
- name: search
in: query
required: false
schema:
anyOf:
- type: string
description: Search by role_name (case-insensitive)
title: Search
description: Search by role_name (case-insensitive)
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/TenantRoleListResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
post:
security:
- CognitoAuthorizer: []
tags:
- roles
summary: Create Role
description: Create a new tenant role. role_code is auto-generated.
operationId: create_role_tenants__tenant_id__roles_post
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TenantRoleCreateRequest"
responses:
"201":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/TenantRoleDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/roles/{role_identifier}:
get:
security:
- CognitoAuthorizer: []
tags:
- roles
summary: Get Role
description: Get a single active role by role_code or role_internal_id (UUID).
operationId: get_role_tenants__tenant_id__roles__role_identifier__get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: role_identifier
in: path
required: true
schema:
type: string
title: Role Identifier
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/TenantRoleDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
patch:
security:
- CognitoAuthorizer: []
tags:
- roles
summary: Update Role
description: Partially update an active role. At least one field must be provided.
operationId: update_role_tenants__tenant_id__roles__role_identifier__patch
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: role_identifier
in: path
required: true
schema:
type: string
title: Role Identifier
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/TenantRoleUpdateRequest"
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/TenantRoleDetailResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
delete:
security:
- CognitoAuthorizer: []
tags:
- roles
summary: Delete Role
description: Soft-delete an active role. Blocked if users are still assigned to it.
operationId: delete_role_tenants__tenant_id__roles__role_identifier__delete
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: role_identifier
in: path
required: true
schema:
type: string
title: Role Identifier
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"204":
description: Successful Response
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/timezones:
get:
security:
- CognitoAuthorizer: []
tags:
- timezones
summary: Get Timezones
description: |
Return list of IANA timezones with pagination.
Query Parameters:
- page: Page number (default: 1)
- page_size: Items per page (default: 50, max: 1000)
- search: Filter by timezone name (case-insensitive partial match)
operationId: get_timezones_tenants__tenant_id__timezones_get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: page
in: query
required: false
schema:
type: integer
minimum: 1
description: Page number
default: 1
title: Page
description: Page number
- name: page_size
in: query
required: false
schema:
type: integer
maximum: 1000
minimum: 1
description: Items per page
default: 50
title: Page Size
description: Items per page
- name: search
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Search by timezone name (case-insensitive)
title: Search
description: Search by timezone name (case-insensitive)
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/TimezonesListResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants/{tenant_id}/regions:
get:
security:
- CognitoAuthorizer: []
tags:
- regions
summary: Get Regions
description: |
Return all regions for a tenant from ClickHouse.
Query Parameters:
- page: Page number (default: 1)
- page_size: Items per page (default: 50, max: 1000)
- search: Filter by territory name (case-insensitive partial match)
operationId: get_regions_tenants__tenant_id__regions_get
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
title: Tenant Id
- name: page
in: query
required: false
schema:
type: integer
minimum: 1
description: Page number
default: 1
title: Page
description: Page number
- name: page_size
in: query
required: false
schema:
type: integer
maximum: 1000
minimum: 1
description: Items per page
default: 50
title: Page Size
description: Items per page
- name: search
in: query
required: false
schema:
anyOf:
- type: string
- type: "null"
description: Search by territory name
title: Search
description: Search by territory name
- name: X-User-Id
in: header
required: true
schema:
type: string
title: X-User-Id
responses:
"200":
description: Successful Response
content:
application/json:
schema:
$ref: "#/components/schemas/RegionsListResponse"
"422":
description: Validation Error
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/tenants:
get:
security:
- CognitoAuthorizer: []
summary: List all active tenants
tags:
- tenants
operationId: listTenants
parameters:
- name: page
in: query
schema:
type: integer
default: 1
- name: page_size
in: query
schema:
type: integer
default: 25
- name: status
in: query
schema:
type: string
description: Filter by status code
- name: subscription_tier
in: query
schema:
type: string
description: Filter by subscription plan code
responses:
"200":
description: List of tenants
content:
application/json:
schema:
type: object
properties:
data:
type: object
post:
security:
- CognitoAuthorizer: []
summary: Create a new tenant
tags:
- tenants
operationId: createTenant
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name:
type: string
status:
type: string
subscription_tier:
type: string
responses:
"201":
description: Tenant created
content:
application/json:
schema:
type: object
properties:
data:
type: object
/tenants/{tenant_id}:
get:
security:
- CognitoAuthorizer: []
summary: Get a single tenant by UUID
tags:
- tenants
operationId: getTenant
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Tenant details
content:
application/json:
schema:
type: object
properties:
data:
type: object
"404":
description: Tenant not found
patch:
security:
- CognitoAuthorizer: []
summary: Update a tenant
tags:
- tenants
operationId: updateTenant
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
status:
type: string
subscription_tier:
type: string
subscription_end_date:
type: string
format: date
responses:
"200":
description: Tenant updated
content:
application/json:
schema:
type: object
properties:
data:
type: object
delete:
security:
- CognitoAuthorizer: []
summary: Soft-delete a tenant
tags:
- tenants
operationId: deleteTenant
parameters:
- name: tenant_id
in: path
required: true
schema:
type: string
format: uuid
responses:
"200":
description: Tenant deleted
# ── Activity Service ───────────────────────────────────────────────────────
/activity/integrations/{provider}/connections/authorize:
post:
security:
- CognitoAuthorizer: []
summary: Start or restart an OAuth authorization flow
tags:
- integrations
operationId: authorizeConnection
parameters:
- name: provider
in: path
required: true
schema:
type: string
description: OAuth provider slug (currently only google)
requestBody:
required: true
content:
application/json:
schema:
$ref: "#/components/schemas/GoogleAuthorizeRequest"
responses:
"200":
description: Authorization flow started
content:
application/json:
schema:
$ref: "#/components/schemas/GoogleAuthorizeResponse"
"404":
description: Unsupported provider
"422":
description: Request validation failed
content:
application/json:
schema:
$ref: "#/components/schemas/HTTPValidationError"
/activity/integrations/{provider}/connections/{connection_id}:
get:
security:
- CognitoAuthorizer: []
summary: Get connection status by UUID
tags:
- integrations
operationId: getConnectionStatus
parameters:
- name: provider
in: path
required: true
schema:
type: string
description: OAuth provider slug (currently only google)
- name: connection_id
in: path
required: true
schema:
type: string
format: uuid
description: UUID primary key of the oauth_connection record
responses:
"200":
description: Connection found
content:
application/json:
schema:
$ref: "#/components/schemas/ConnectionStatusResponse"
"404":
description: Connection not found
/activity/integrations/{provider}/connections:
get:
security:
- CognitoAuthorizer: []
summary: Look up connection status by user_id and/or authenticated_email
tags:
- integrations
operationId: getConnectionStatusByEmail
parameters:
- name: provider
in: path
required: true
schema:
type: string
description: OAuth provider slug (currently only google)
- name: user_id
in: query
required: false
schema:
type: string
description: User identifier (e.g. email address)
- name: authenticated_email
in: query
required: false
schema:
type: string
description: Provider account email captured during the OAuth consent flow — alternative lookup key.
- name: tenant_id
in: query
required: false
schema:
type: string
format: uuid
description: Owning tenant UUID. Omit to match any tenant.
responses:
"200":
description: Connection found
content:
application/json:
schema:
$ref: "#/components/schemas/ConnectionStatusResponse"
"400":
description: Neither user_id nor authenticated_email provided
"404":
description: Connection not found
/activity/integrations/{provider}/callback:
get:
summary: OAuth callback (AgentCore redirects the user here)
tags:
- integrations
operationId: oauthCallback
security:
parameters:
- name: provider
in: path
required: true
schema:
type: string
description: OAuth provider slug (currently only google)
- name: session_id
in: query
required: false
schema:
type: string
description: AgentCore session URI echoed back on redirect
- name: state
in: query
required: false
schema:
type: string
description: HMAC-signed state token
responses:
"200":
description: OAuth flow completed — HTML success page
content:
text/html:
schema:
type: string
"400":
description: Missing session_id, unverifiable state, or completion error — HTML failure page
content:
text/html:
schema:
type: string
/activity/integrations/google/account-summary:
get:
security:
- CognitoAuthorizer: []
summary: Show connected Google account data (last 10 emails + this month's events)
tags:
- google
operationId: getGoogleAccountSummary
parameters:
- name: user_id
in: query
required: false
schema:
type: string
description: User identifier (e.g. email address)
- name: authenticated_email
in: query
required: false
schema:
type: string
description: Google account email captured during the OAuth consent flow — alternative lookup key.
- name: tenant_id
in: query
required: false
schema:
type: string
format: uuid
description: Tenant UUID. Omit to match any tenant.
responses:
"200":
description: Account summary (connected or not)
content:
application/json:
schema:
$ref: "#/components/schemas/AccountSummaryResponse"
"400":
description: Neither user_id nor authenticated_email provided
"404":
description: Connection not found
"502":
description: Could not retrieve stored Google credentials from AgentCore
components:
securitySchemes:
CognitoAuthorizer:
type: apiKey
name: Authorization
in: header
schemas:
UserCreateRequest:
type: object
properties:
first_name:
type: string
last_name:
type: string
email:
type: string
person_type:
type: string
is_active:
type: boolean
required:
- first_name
- last_name
- email
UserUpdateRequest:
type: object
properties:
first_name:
type: string
last_name:
type: string
person_type:
type: string
is_active:
type: boolean
region:
type: string
department:
type: string
hierarchylistresponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/HierarchyListResponse"
type: object
title: hierarchylistresponse
WrappedHierarchyMutationResponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/HierarchyMutationResponse"
type: object
title: HierarchyMutationResponse
WrappedHierarchyNodeDetailResponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/HierarchyNodeDetailResponse"
type: object
title: HierarchyNodeDetailResponse
TenantRoleDetailResponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/TenantRoleDetailResponse"
type: object
title: TenantRoleDetailResponse
TenantRoleListResponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/TenantRoleListResponse"
type: object
title: TenantRoleListResponse
UserDetailResponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/UserDetailResponse"
type: object
title: UserDetailResponse
UserGroupDetailResponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/UserGroupDetailResponse"
type: object
title: UserGroupDetailResponse
UserGroupListResponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/UserGroupListResponse"
type: object
title: UserGroupListResponse
TagDetailResponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/WrappedTagDetailResponse"
type: object
title: TagDetailResponse
TagListResponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/WrappedTagListResponse"
type: object
title: TagListResponse
UsersListResponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/UsersListResponse"
type: object
title: UsersListResponse
TimezonesListResponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/TimezonesListData"
type: object
title: TimezonesListResponse
TimezonesListData:
properties:
timezones:
items:
$ref: "#/components/schemas/Timezone"
type: array
title: Timezones
pagination:
$ref: "#/components/schemas/Pagination"
type: object
required:
- timezones
- pagination
title: TimezonesListData
Timezone:
properties:
name:
type: string
title: Name
type: object
required:
- name
title: Timezone
RegionsListResponse:
properties:
data:
anyOf:
- $ref: "#/components/schemas/RegionsListData"
type: object
title: RegionsListResponse
RegionsListData:
properties:
regions:
items:
$ref: "#/components/schemas/Region"
type: array
title: Regions
pagination:
$ref: "#/components/schemas/Pagination"
type: object
required:
- regions
- pagination
title: RegionsListData
Region:
properties:
territory_internal_id:
type: string
title: Territory Internal Id
tenant_id:
type: string
title: Tenant Id
source_system:
anyOf:
- type: string
- type: "null"
title: Source System
source_territory_id:
type: string
title: Source Territory Id
territory_name:
type: string
title: Territory Name
manager_person_id:
anyOf:
- type: string
- type: "null"
title: Manager Person Id
parent_territory_id:
anyOf:
- type: string
- type: "null"
title: Parent Territory Id
reporting_datetime:
anyOf:
- type: string
- type: "null"
title: Reporting Datetime
created_by:
anyOf:
- type: string
- type: "null"
title: Created By
updated_by:
anyOf:
- type: string
- type: "null"
title: Updated By
created_at:
anyOf:
- type: string
- type: "null"
title: Created At
updated_at:
anyOf:
- type: string
- type: "null"
title: Updated At
type: object
required:
- territory_internal_id
- tenant_id
- source_territory_id
- territory_name
title: Region
HealthData:
properties:
status:
type: string
title: Status
default: healthy
database:
type: string
title: Database
default: healthy
cache:
anyOf:
- type: string
title: Cache
type: object
title: HealthData
description: Data for health check endpoint.
HierarchyActionRequest:
properties:
action:
type: string
enum:
- approve
- reject
title: Action
notes:
anyOf:
- type: string
title: Notes
additionalProperties: false
type: object
required:
- action
title: HierarchyActionRequest
description: Request body for hierarchy approval actions.
HierarchyDirectReport:
properties:
nodeId:
type: string
title: Nodeid
roleId:
anyOf:
- type: string
title: Roleid
role:
anyOf:
- $ref: "#/components/schemas/TenantRoleResponse"
userCount:
type: integer
title: Usercount
default: 0
type: object
required:
- nodeId
title: HierarchyDirectReport
description: Summary for a direct child node in a hierarchy.
HierarchyListResponse:
properties:
hierarchies:
items:
$ref: "#/components/schemas/HierarchyRevisionResponse"
type: array
title: Hierarchies
pagination:
$ref: "#/components/schemas/Pagination"
type: object
required:
- hierarchies
- pagination
title: HierarchyListResponse
description: List response for hierarchy revisions.
HierarchyMutationResponse:
properties:
message:
type: string
title: Message
action:
type: string
title: Action
revision:
anyOf:
- $ref: "#/components/schemas/HierarchyRevisionResponse"
type: object
required:
- message
- action
title: HierarchyMutationResponse
description: Success payload for hierarchy update requests.
HierarchyNode:
properties:
nodeId:
type: string
title: Nodeid
roleId:
anyOf:
- type: string
title: Roleid
regionId:
anyOf:
- items:
type: string
type: array
title: Regionid
parentId:
anyOf:
- type: string
title: Parentid
type: object
required:
- nodeId
title: HierarchyNode
description: One hierarchy node in the tenant hierarchy tree.
HierarchyNodeDetailResponse:
properties:
hierarchy_revision_id:
type: string
title: Hierarchy Revision Id
node:
$ref: "#/components/schemas/HierarchyNode"
role:
anyOf:
- $ref: "#/components/schemas/TenantRoleResponse"
directReports:
items:
$ref: "#/components/schemas/HierarchyDirectReport"
type: array
title: Directreports
type: object
required:
- hierarchy_revision_id
- node
title: HierarchyNodeDetailResponse
description: Response payload for a single hierarchy node.
HierarchyRevisionResponse:
properties:
revision_id:
type: string
title: Revision Id
tenant_id:
type: string
title: Tenant Id
author_person_id:
type: string
title: Author Person Id
parent_revision_id:
anyOf:
- type: string
title: Parent Revision Id
hierarchy_payload:
additionalProperties: true
type: object
title: Hierarchy Payload
user_changes:
additionalProperties: true
type: object
title: User Changes
required_approval_count:
type: integer
title: Required Approval Count
current_approval_count:
type: integer
title: Current Approval Count
approved_by_users:
items:
type: string
type: array
title: Approved By Users
pending_approvers:
items:
type: string
type: array
title: Pending Approvers
status:
type: string
title: Status
is_current:
type: boolean
title: Is Current
created_at:
type: string
title: Created At
updated_at:
type: string
title: Updated At
expires_at:
anyOf:
- type: string
title: Expires At
type: object
required:
- revision_id
- tenant_id
- author_person_id
- hierarchy_payload
- user_changes
- required_approval_count
- current_approval_count
- approved_by_users
- pending_approvers
- status
- is_current
- created_at
- updated_at
title: HierarchyRevisionResponse
description: Response model for a hierarchy revision row.
HierarchyUpdateRequest:
properties:
hasHierarchyChanges:
type: boolean
title: Hashierarchychanges
default: false
hasUserChanges:
type: boolean
title: Hasuserchanges
default: false
hasUserGroupChanges:
type: boolean
title: Hasusergroupchanges
default: false
hierarchy:
items:
$ref: "#/components/schemas/HierarchyNode"
type: array
title: Hierarchy
userAssignments:
items:
$ref: "#/components/schemas/HierarchyUserAssignment"
type: array
title: Userassignments
userReporting:
items:
$ref: "#/components/schemas/HierarchyUserReporting"
type: array
title: Userreporting
userGroups:
items:
$ref: "#/components/schemas/HierarchyUserGroupAssignment"
type: array
title: Usergroups
additionalProperties: false
type: object
title: HierarchyUpdateRequest
description: Request body for hierarchy revision updates.
HierarchyUserAssignment:
properties:
userId:
type: string
title: Userid
nodeId:
type: string
title: Nodeid
roleId:
type: string
title: Roleid
regionId:
anyOf:
- items:
type: string
type: array
title: Regionid
isPlayerCoach:
type: boolean
title: Isplayercoach
default: false
type: object
required:
- userId
- nodeId
- roleId
title: HierarchyUserAssignment
description: Map a user to a hierarchy node and role.
HierarchyUserGroupAssignment:
properties:
userId:
type: string
title: Userid
userGroupId:
type: string
title: Usergroupid
type: object
required:
- userId
- userGroupId
title: HierarchyUserGroupAssignment
description: Map one user to one user group.
HierarchyUserReporting:
properties:
userId:
type: string
title: Userid
managerId:
type: string
title: Managerid
nodeId:
type: string
title: Nodeid
type: object
required:
- userId
- managerId
- nodeId
title: HierarchyUserReporting
description: Map a user to their reporting manager.
Pagination:
properties:
page:
type: integer
title: Page
page_size:
type: integer
title: Page Size
total:
type: integer
title: Total
total_pages:
type: integer
title: Total Pages
has_next:
type: boolean
title: Has Next
type: object
required:
- page
- page_size
- total
- total_pages
- has_next
title: Pagination
description: Standard pagination metadata.
TenantRoleCreateRequest:
properties:
role_name:
type: string
title: Role Name
role_type:
type: string
title: Role Type
default: leaf
workspace_id:
anyOf:
- type: string
title: Workspace Id
can_view_subtree:
type: boolean
title: Can View Subtree
default: false
can_adjust_forecast:
type: boolean
title: Can Adjust Forecast
default: false
can_submit_forecast:
type: boolean
title: Can Submit Forecast
default: false
can_view_attributed:
type: boolean
title: Can View Attributed
default: false
can_view_team_data:
type: boolean
title: Can View Team Data
default: false
can_view_region_data:
type: boolean
title: Can View Region Data
default: false
can_manage_team:
type: boolean
title: Can Manage Team
default: false
is_revenue_owner:
type: boolean
title: Is Revenue Owner
default: false
can_modify:
type: boolean
title: Can Modify
default: false
type: object
required:
- role_name
title: TenantRoleCreateRequest
description: Request body for creating a new tenant role.
WrappedTenantRoleDetailResponse:
properties:
role:
$ref: "#/components/schemas/TenantRoleResponse"
type: object
required:
- role
title: TenantRoleDetailResponse
description: Single tenant role response.
WrappedTenantRoleListResponse:
properties:
roles:
items:
$ref: "#/components/schemas/TenantRoleResponse"
type: array
title: Roles
pagination:
$ref: "#/components/schemas/Pagination"
type: object
required:
- roles
- pagination
title: TenantRoleListResponse
description: Paginated list of tenant roles.
TenantRoleResponse:
properties:
role_internal_id:
type: string
title: Role Internal Id
tenant_id:
type: string
title: Tenant Id
role_code:
type: string
title: Role Code
role_name:
type: string
title: Role Name
role_type:
type: string
title: Role Type
workspace_id:
anyOf:
- type: string
title: Workspace Id
hierarchy_type:
type: string
title: Hierarchy Type
can_view_subtree:
type: boolean
title: Can View Subtree
can_adjust_forecast:
type: boolean
title: Can Adjust Forecast
can_submit_forecast:
type: boolean
title: Can Submit Forecast
can_view_attributed:
type: boolean
title: Can View Attributed
can_view_team_data:
type: boolean
title: Can View Team Data
can_view_region_data:
type: boolean
title: Can View Region Data
can_manage_team:
type: boolean
title: Can Manage Team
is_revenue_owner:
type: boolean
title: Is Revenue Owner
can_modify:
type: boolean
title: Can Modify
is_active:
type: boolean
title: Is Active
created_at:
type: string
title: Created At
updated_at:
type: string
title: Updated At
type: object
required:
- role_internal_id
- tenant_id
- role_code
- role_name
- role_type
- hierarchy_type
- can_view_subtree
- can_adjust_forecast
- can_submit_forecast
- can_view_attributed
- can_view_team_data
- can_view_region_data
- can_manage_team
- is_revenue_owner
- can_modify
- is_active
- created_at
- updated_at
title: TenantRoleResponse
description: Full representation of a dim_tenant_role row.
TenantRoleUpdateRequest:
properties:
role_name:
anyOf:
- type: string
title: Role Name
role_type:
anyOf:
- type: string
title: Role Type
workspace_id:
anyOf:
- type: string
title: Workspace Id
can_view_subtree:
anyOf:
- type: boolean
title: Can View Subtree
can_adjust_forecast:
anyOf:
- type: boolean
title: Can Adjust Forecast
can_submit_forecast:
anyOf:
- type: boolean
title: Can Submit Forecast
can_view_attributed:
anyOf:
- type: boolean
title: Can View Attributed
can_view_team_data:
anyOf:
- type: boolean
title: Can View Team Data
can_view_region_data:
anyOf:
- type: boolean
title: Can View Region Data
can_manage_team:
anyOf:
- type: boolean
title: Can Manage Team
is_revenue_owner:
anyOf:
- type: boolean
title: Is Revenue Owner
can_modify:
anyOf:
- type: boolean
title: Can Modify
type: object
title: TenantRoleUpdateRequest
description: Request body for partially updating a tenant role.
User:
properties:
person_internal_id:
type: string
title: Person Internal Id
tenant_id:
type: string
title: Tenant Id
source_system:
anyOf:
- type: string
title: Source System
source_person_id:
type: string
title: Source Person Id
manager_source_id:
anyOf:
- type: string
title: Manager Source Id
source_account_id:
anyOf:
- type: string
title: Source Account Id
person_type:
anyOf:
- type: string
title: Person Type
is_active:
anyOf:
- type: boolean
title: Is Active
first_name:
anyOf:
- type: string
title: First Name
last_name:
anyOf:
- type: string
title: Last Name
email_address:
anyOf:
- type: string
title: Email Address
title:
anyOf:
- type: string
title: Title
cal_type:
anyOf:
- type: string
title: Cal Type
job_title:
anyOf:
- type: string
title: Job Title
department:
anyOf:
- type: string
title: Department
standard_role:
anyOf:
- type: string
title: Standard Role
source_role:
anyOf:
- type: string
title: Source Role
do_not_email:
anyOf:
- type: boolean
title: Do Not Email
do_not_phone:
anyOf:
- type: boolean
title: Do Not Phone
region:
anyOf:
- items:
type: string
type: array
title: Region
hierarchy_node_id:
anyOf:
- type: string
title: Hierarchy Node Id
player_coach_flag:
anyOf:
- type: boolean
title: Player Coach Flag
assigned_territory_id:
anyOf:
- type: string
title: Assigned Territory Id
tenant_role_code:
anyOf:
- type: string
title: Tenant Role Code
provisioning_source:
anyOf:
- type: string
title: Provisioning Source
workspace_id:
anyOf:
- type: string
title: Workspace Id
hierarchy_path:
anyOf:
- type: string
title: Hierarchy Path
level_1_id:
anyOf:
- type: string
title: Level 1 Id
level_2_id:
anyOf:
- type: string
title: Level 2 Id
level_3_id:
anyOf:
- type: string
title: Level 3 Id
level_4_id:
anyOf:
- type: string
title: Level 4 Id
level_5_id:
anyOf:
- type: string
title: Level 5 Id
level_6_id:
anyOf:
- type: string
title: Level 6 Id
level_7_id:
anyOf:
- type: string
title: Level 7 Id
level_8_id:
anyOf:
- type: string
title: Level 8 Id
level_9_id:
anyOf:
- type: string
title: Level 9 Id
level_10_id:
anyOf:
- type: string
title: Level 10 Id
user_group_id:
anyOf:
- type: string
title: User Group Id
reporting_datetime:
anyOf:
- type: string
title: Reporting Datetime
type: object
required:
- person_internal_id
- tenant_id
- source_person_id
title: User
description: Full representation of a dim_person row.
WrappedUserDetailResponse:
properties:
user:
$ref: "#/components/schemas/User"
role:
anyOf:
- $ref: "#/components/schemas/TenantRoleResponse"
type: object
required:
- user
title: UserDetailResponse
description: Single user response — role contains the full dim_tenant_role row.
UserGroupCreateRequest:
properties:
name:
type: string
title: Name
description:
anyOf:
- type: string
title: Description
rules:
items: {}
type: array
title: Rules
type: object
required:
- name
- rules
title: UserGroupCreateRequest
UserGroupUpdateRequest:
properties:
description:
anyOf:
- type: string
title: Description
rules:
anyOf:
- items: {}
type: array
title: Rules
type: object
title: UserGroupUpdateRequest
WrappedTagDetailResponse:
properties:
tag:
$ref: "#/components/schemas/TagResponse"
type: object
required:
- tag
title: TagDetailResponse
WrappedTagListResponse:
properties:
tags:
items:
$ref: "#/components/schemas/TagResponse"
type: array
title: Tags
pagination:
$ref: "#/components/schemas/Pagination"
type: object
required:
- tags
- pagination
title: TagListResponse
TagResponse:
properties:
tag_id:
type: string
title: Tag Id
tenant_id:
type: string
title: Tenant Id
tag_name:
type: string
title: Tag Name
description:
anyOf:
- type: string
title: Description
permissions:
anyOf:
- items:
type: string
type: array
title: Permissions
is_active:
type: boolean
title: Is Active
created_by:
anyOf:
- type: string
title: Created By
updated_by:
anyOf:
- type: string
title: Updated By
created_at:
type: string
title: Created At
updated_at:
type: string
title: Updated At
type: object
required:
- tag_id
- tenant_id
- tag_name
- is_active
- created_at
- updated_at
title: TagResponse
TagCreateRequest:
properties:
name:
type: string
maxLength: 100
minLength: 1
title: Name
description:
anyOf:
- type: string
title: Description
permissions:
anyOf:
- items:
type: string
type: array
title: Permissions
type: object
required:
- name
title: TagCreateRequest
TagUpdateRequest:
properties:
name:
anyOf:
- type: string
maxLength: 100
minLength: 1
title: Name
description:
anyOf:
- type: string
title: Description
permissions:
anyOf:
- items:
type: string
type: array
title: Permissions
type: object
title: TagUpdateRequest
WrappedUserGroupDetailResponse:
properties:
user_group:
$ref: "#/components/schemas/UserGroupResponse"
type: object
required:
- user_group
title: UserGroupDetailResponse
WrappedUserGroupListResponse:
properties:
user_groups:
items:
$ref: "#/components/schemas/UserGroupResponse"
type: array
title: User Groups
pagination:
$ref: "#/components/schemas/Pagination"
type: object
required:
- user_groups
- pagination
title: UserGroupListResponse
UserGroupResponse:
properties:
user_group_id:
type: string
title: User Group Id
tenant_id:
type: string
title: Tenant Id
group_name:
type: string
title: Group Name
description:
anyOf:
- type: string
title: Description
rules:
items: {}
type: array
title: Rules
is_active:
type: boolean
title: Is Active
created_by:
type: string
title: Created By
updated_by:
anyOf:
- type: string
title: Modified By
created_at:
type: string
title: Created At
updated_at:
type: string
title: Updated At
type: object
required:
- user_group_id
- tenant_id
- group_name
- rules
- is_active
- created_by
- created_at
- updated_at
title: UserGroupResponse
WrappedUsersListResponse:
properties:
users:
items:
$ref: "#/components/schemas/User"
type: array
title: Users
pagination:
anyOf:
- $ref: "#/components/schemas/Pagination"
search_cursor:
anyOf:
- type: string
title: Search Cursor
is_search:
anyOf:
- type: boolean
title: Is Search
type: object
required:
- users
title: UsersListResponse
WrappedHealthData:
properties:
status:
type: string
title: Status
description: Response status
default: success
code:
type: integer
title: Code
description: HTTP status code
default: 200
data:
anyOf:
- $ref: "#/components/schemas/HealthData"
message:
anyOf:
- type: string
title: Message
type: object
title: WrappedHealthData
Opportunity:
type: object
properties:
opportunity_internal_id:
type: string
format: uuid
tenant_id:
type: string
format: uuid
source_system:
type: string
source_opportunity_id:
type: string
source_account_id:
type: string
source_owner_id:
type: string
originating_lead_id:
type: string
opportunity_name:
type: string
source_opportunity_type:
type: string
standard_opportunity_type:
type: string
source_sales_stage:
type: string
standard_sales_stage:
type: string
source_forecast_category:
type: string
standard_forecast_category:
type: string
estimated_value:
type: number
acv_amount:
type: number
new_booking_amount:
type: number
renewal_booking_amount:
type: number
previous_acv_amount:
type: number
total_discount_amount:
type: number
weighted_value:
type: number
close_probability:
type: integer
estimated_close_date:
type: string
format: date-time
contract_expiry_date:
type: string
format: date-time
entitlement_expiry_date:
type: string
format: date-time
budget_status:
type: string
budget_amount:
type: number
purchase_timeframe:
type: string
purchase_process:
type: string
next_step_text:
type: string
next_step_date:
type: string
format: date-time
closed_lost_reason:
type: string
closed_won_reason:
type: string
rep_risk_notes:
type: string
is_proposal_presented:
type: boolean
is_active_trial:
type: boolean
meddpicc_metrics_met:
type: boolean
meddpicc_economic_buyer_met:
type: boolean
meddpicc_decision_criteria_met:
type: boolean
meddpicc_decision_process_met:
type: boolean
meddpicc_paper_process_met:
type: boolean
meddpicc_identify_pain_met:
type: boolean
meddpicc_champion_met:
type: boolean
meddpicc_competition_met:
type: boolean
meddpicc_completion_score:
type: number
meddpicc_rep_evidence:
type: object
meddpicc_ai_audit:
type: object
meddpicc_last_audited_at:
type: string
format: date-time
executive_sponsor_person_id:
type: string
actual_close_date:
type: string
format: date-time
actual_revenue:
type: number
is_won:
type: boolean
is_closed:
type: boolean
territory_id:
type: string
territory_name:
type: string
campaign_id:
type: string
partner_id:
type: string
engagement_model:
type: string
customer_success_manager_id:
type: string
level_1_id:
type: string
level_2_id:
type: string
level_3_id:
type: string
level_4_id:
type: string
level_5_id:
type: string
level_6_id:
type: string
level_7_id:
type: string
level_8_id:
type: string
level_9_id:
type: string
level_10_id:
type: string
user_group_id:
type: string
format: uuid
reporting_datetime:
type: string
format: date-time
required:
- opportunity_internal_id
- tenant_id
- source_opportunity_id
- opportunity_name
- is_won
- is_closed
title: Opportunity
description: Comprehensive representation of an opportunity from dim_opportunity.
WrappedOpportunityDetailResponse:
type: object
properties:
data:
$ref: "#/components/schemas/Opportunity"
required:
- data
title: OpportunityDetailResponse
OpportunityStatistics:
type: object
properties:
total_pipeline:
type: number
open_deals:
type: integer
closing_soon:
type: integer
in_negotiation:
type: integer
won_deals:
type: integer
required:
- total_pipeline
- open_deals
- closing_soon
- in_negotiation
- won_deals
title: OpportunityStatistics
WrappedOpportunityListResponse:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/Opportunity"
pagination:
$ref: "#/components/schemas/Pagination"
statistics:
$ref: "#/components/schemas/OpportunityStatistics"
required:
- data
- pagination
- statistics
title: OpportunityListResponse
DealDetail:
type: object
properties:
opportunity_id:
type: string
opportunity_name:
type: string
revenue:
type: number
opportunity_type:
type: string
submit_type:
type: string
enum: [commit, upside]
close_date:
type: string
format: date-time
required:
- opportunity_id
- opportunity_name
- revenue
- opportunity_type
- submit_type
ForecastSubmissionRequest:
type: object
properties:
target_quarter:
type: string
example: FY26-Q2
cadence:
type: string
example: WEEKLY
cadence_label:
type: string
example: Week 3
revenue_type:
type: string
example: New
is_draft:
type: boolean
default: false
commit_amount:
type: number
upside_amount:
type: number
breakdown:
type: array
items:
$ref: "#/components/schemas/DealDetail"
notes:
type: string
default: ""
required:
- target_quarter
- cadence
- cadence_label
- revenue_type
- commit_amount
- upside_amount
- breakdown
ForecastActionResponse:
type: object
properties:
success:
type: boolean
message:
type: string
submission_id:
type: string
format: uuid
revision_id:
type: string
format: uuid
required:
- success
- message
ManagerAdjustmentRequest:
type: object
properties:
target_user_id:
type: string
target_quarter:
type: string
cadence:
type: string
cadence_label:
type: string
revenue_type:
type: string
initial_commit:
type: number
adjusted_commit:
type: number
breakdown:
type: array
items:
$ref: "#/components/schemas/DealDetail"
is_draft:
type: boolean
default: false
notes:
type: string
default: ""
required:
- target_user_id
- target_quarter
- cadence
- cadence_label
- revenue_type
- initial_commit
- adjusted_commit
- breakdown
PipelineMath:
type: object
properties:
direct_commit:
type: number
default: 0
team_rollup_commit:
type: number
default: 0
total_calculated_commit:
type: number
default: 0
direct_open_opportunity_revenue:
type: number
default: 0
team_open_opportunity_revenue:
type: number
default: 0
PersonalSubmission:
type: object
properties:
user_submitted_commit:
type: number
user_submitted_breakdown:
type: array
items:
type: object
BossAdjustment:
type: object
properties:
manager_adjusted_commit:
type: number
manager_adjusted_breakdown:
type: array
items:
type: object
latest_adjuster_name:
type: string
latest_adjustment_note:
type: string
UIFlags:
type: object
properties:
is_out_of_sync:
type: boolean
default: false
MyForecastDetail:
type: object
properties:
submission_id:
type: string
owner_id:
type: string
status:
type: string
default: NOT_STARTED
last_action_date:
type: string
format: date-time
pipeline_math:
$ref: "#/components/schemas/PipelineMath"
my_submission:
$ref: "#/components/schemas/PersonalSubmission"
boss_adjustment:
$ref: "#/components/schemas/BossAdjustment"
ui_flags:
$ref: "#/components/schemas/UIFlags"
TeamAdjustmentOnRep:
type: object
properties:
manager_adjusted_commit:
type: number
latest_adjustment_note:
type: string
RepSubmissionSummary:
type: object
properties:
user_submitted_commit:
type: number
user_submitted_breakdown:
type: array
items:
type: object
last_action_date:
type: string
format: date-time
TeamForecastSummary:
type: object
properties:
submission_id:
type: string
rep_id:
type: string
rep_name:
type: string
status:
type: string
rep_submission:
$ref: "#/components/schemas/RepSubmissionSummary"
my_adjustment_on_them:
$ref: "#/components/schemas/TeamAdjustmentOnRep"
open_opportunity_revenue:
type: number
default: 0
ForecastContext:
type: object
properties:
target_quarter:
type: string
cadence_label:
type: string
revenue_type:
type: string
UserForecastResponse:
type: object
properties:
context:
$ref: "#/components/schemas/ForecastContext"
my_forecast:
$ref: "#/components/schemas/MyForecastDetail"
team_forecasts:
type: array
items:
$ref: "#/components/schemas/TeamForecastSummary"
quota_amount:
type: number
default: 0
direct_open_opportunity_revenue:
type: number
default: 0
team_open_opportunity_revenue:
type: number
default: 0
QuotaCreateRequest:
type: object
properties:
reportee_id:
type: string
description: Person internal ID of the reportee
quarter:
type: string
description: Fiscal quarter e.g. FY2026-Q2
example: FY2026-Q2
quota_amount:
type: number
minimum: 0
description: Quota amount for the quarter
is_draft:
type: boolean
default: false
description: Whether to save as draft or submit officially
notes:
type: string
default: ""
required:
- reportee_id
- quarter
- quota_amount
QuotaUpdateRequest:
type: object
properties:
quota_amount:
type: number
minimum: 0
description: Updated quota amount
is_draft:
type: boolean
description: Set to False to finalize a draft quota
notes:
type: string
default: ""
required:
- quota_amount
QuotaResponse:
type: object
properties:
quota_internal_id:
type: string
format: uuid
tenant_id:
type: string
format: uuid
reportee_id:
type: string
quarter:
type: string
quota_amount:
type: string
quota_status:
type: string
is_locked:
type: boolean
notes:
type: string
created_by:
type: string
updated_by:
type: string
created_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
required:
- quota_internal_id
- tenant_id
- reportee_id
- quarter
- quota_amount
- quota_status
- is_locked
- notes
- created_by
- updated_by
- created_at
- updated_at
QuotaListResponse:
type: object
properties:
quotas:
type: array
items:
$ref: "#/components/schemas/QuotaResponse"
total:
type: integer
required:
- quotas
- total
AuditResponse:
type: object
properties:
items:
type: array
items:
type: object
additionalProperties: true
title: Items
next_token:
anyOf:
- type: string
- type: "null"
title: Next Token
count:
type: integer
title: Count
required:
- items
- count
title: AuditResponse
HTTPValidationError:
type: object
properties:
detail:
type: array
items:
$ref: "#/components/schemas/ValidationError"
title: Detail
title: HTTPValidationError
ValidationError:
type: object
properties:
loc:
type: array
items:
anyOf:
- type: string
- type: integer
title: Location
msg:
type: string
title: Message
type:
type: string
title: Error Type
required:
- loc
- msg
- type
title: ValidationError
# ── Activity Service Schemas ────────────────────────────────────────────
ConnectionStatus:
type: string
description: Lifecycle state of an OAuth connection.
enum: [not_connected, pending, success, failed, revoked]
HealthResponse:
type: object
required: [status]
properties:
status:
type: string
GoogleAuthorizeRequest:
type: object
required: [user_id]
properties:
user_id:
type: string
description: User identifier (e.g. email address).
tenant_id:
type: string
format: uuid
description: Owning tenant UUID. Omit when not multi-tenant.
service_id:
type: string
description: Optional external service/group identifier.
GoogleAuthorizeResponse:
type: object
required: [connection_id, user_id, connection_type, authorization_url, status]
properties:
connection_id:
type: string
format: uuid
user_id:
type: string
connection_type:
type: string
authorization_url:
type: string
description: Google consent URL — redirect the end-user here.
status:
$ref: "#/components/schemas/ConnectionStatus"
ConnectionStatusResponse:
type: object
required: [connection_id, user_id, connection_type, status, connected, scopes, error]
properties:
connection_id:
type: string
format: uuid
user_id:
type: string
tenant_id:
type: string
format: uuid
service_id:
type: string
connection_type:
type: string
description: OAuth provider identifier (e.g. "google-auth", "outlook-auth").
status:
$ref: "#/components/schemas/ConnectionStatus"
connected:
type: boolean
description: "True when status is 'success'."
connected_at:
type: string
format: date-time
updated_at:
type: string
format: date-time
scopes:
type: array
items:
type: string
error:
type: string
description: Last error message; empty string when no error.
authenticated_email:
type: string
description: Google account email used during the OAuth consent flow. Populated after the first successful callback; null until then.
AccountSummaryResponse:
type: object
required: [user_id, connection_type, status, connected]
properties:
user_id:
type: string
authenticated_email:
type: string
description: Google account email captured during the OAuth consent flow.
connection_id:
type: string
format: uuid
connection_type:
type: string
status:
$ref: "#/components/schemas/ConnectionStatus"
connected:
type: boolean
message:
type: string
description: Set when connected=false; explains what action to take.
emails:
type: array
items:
$ref: "#/components/schemas/GmailMessage"
events:
type: array
items:
$ref: "#/components/schemas/CalendarEvent"
GmailMessage:
type: object
required: [id, subject, sender, date, snippet]
properties:
id:
type: string
description: Gmail message ID.
subject:
type: string
sender:
type: string
date:
type: string
snippet:
type: string
CalendarEvent:
type: object
required: [id, summary, start, end]
properties:
id:
type: string
description: Google Calendar event ID.
summary:
type: string
start:
type: string
description: ISO 8601 date-time or all-day date.
end:
type: string
description: ISO 8601 date-time or all-day date.
location:
type: string
organizer:
type: string
description: Organizer's email address.
ProductFamily:
type: object
properties:
product_family_id:
type: string
product_family:
type: string
required:
- product_family_id
- product_family
title: ProductFamily
ProductFamilyListResponse:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/ProductFamily"
pagination:
$ref: "#/components/schemas/Pagination"
required:
- data
- pagination
title: ProductFamilyListResponse
description: Paginated list of product families.
OpportunityProduct:
type: object
properties:
opp_product_internal_id:
type: string
format: uuid
source_opp_product_id:
type: string
source_product_id:
type: string
quantity:
type: number
unit_price:
type: number
unit_price_base:
type: number
new_revenue_amount:
type: number
new_revenue_amount_base:
type: number
renewal_revenue_amount:
type: number
renewal_revenue_amount_base:
type: number
upside_amount:
type: number
upside_amount_base:
type: number
estimated_bcv:
type: number
validated_bcv:
type: number
family_total_amount:
type: number
family_total_amount_base:
type: number
new_discount_amount:
type: number
new_discount_amount_base:
type: number
new_discount_percentage:
type: number
renewal_discount_amount:
type: number
renewal_discount_amount_base:
type: number
renewal_discount_percentage:
type: number
exchange_rate:
type: number
term_length:
type: string
product_category:
type: string
product_subcategory:
type: string
source_revenue_type:
type: string
standard_revenue_type:
type: string
currency_code:
type: string
is_active:
type: boolean
product_name:
type: string
part_number:
type: string
product_family:
type: string
required:
- opp_product_internal_id
- source_opp_product_id
- source_product_id
- currency_code
- is_active
title: OpportunityProduct
description: A single product line item associated with an opportunity.
OpportunityProductsListResponse:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/OpportunityProduct"
pagination:
$ref: "#/components/schemas/Pagination"
required:
- data
- pagination
title: OpportunityProductsListResponse
description: Paginated list of products for an opportunity.
OpportunityQuote:
type: object
properties:
quote_internal_id:
type: string
format: uuid
source_quote_id:
type: string
quote_name:
type: string
discount_approval_status:
type: string
legal_approval_status:
type: string
total_amount:
type: number
total_amount_base:
type: number
currency_code:
type: string
status:
type: string
expires_on:
type: string
format: date-time
created_by:
type: string
account_name:
type: string
created_at:
type: string
format: date-time
required:
- quote_internal_id
- source_quote_id
- quote_name
- currency_code
- status
title: OpportunityQuote
description: A single quote associated with an opportunity.
OpportunityQuotesListResponse:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/OpportunityQuote"
pagination:
$ref: "#/components/schemas/Pagination"
required:
- data
- pagination
title: OpportunityQuotesListResponse
description: Paginated list of quotes for an opportunity.
OpportunitySalesTeamMember:
type: object
properties:
team_member_internal_id:
type: string
format: uuid
source_person_id:
type: string
team_role:
type: string
tenant_role_code:
type: string
product_family:
type: string
first_name:
type: string
last_name:
type: string
email_address:
type: string
job_title:
type: string
department:
type: string
role_name:
type: string
required:
- team_member_internal_id
- source_person_id
- team_role
- tenant_role_code
- product_family
title: OpportunitySalesTeamMember
description: A deal team member associated with an opportunity.
OpportunitySalesTeamListResponse:
type: object
properties:
data:
type: array
items:
$ref: "#/components/schemas/OpportunitySalesTeamMember"
pagination:
$ref: "#/components/schemas/Pagination"
required:
- data
- pagination
title: OpportunitySalesTeamListResponse
description: Paginated list of sales team members for an opportunity.
WrappedOpportunityHistoryListResponse:
type: object
properties:
data:
type: array
items:
type: object
additionalProperties: true
pagination:
$ref: "#/components/schemas/Pagination"
title: WrappedOpportunityHistoryListResponse
description: Paginated list of opportunity history snapshots.
TeamForecastsResponse:
type: object
properties:
context:
$ref: "#/components/schemas/ForecastContext"
team_forecasts:
type: array
items:
$ref: "#/components/schemas/TeamForecastSummary"
title: TeamForecastsResponse
description: Response containing team forecast submissions for a manager.
TeamOverviewResponse:
type: object
properties:
context:
$ref: "#/components/schemas/ForecastContext"
team_members:
type: array
items:
type: object
additionalProperties: true
total_team_commit:
type: number
default: 0
total_team_open_pipeline:
type: number
default: 0
title: TeamOverviewResponse
description: High-level team overview with aggregated metrics.
QuotaHistoryResponse:
type: object
properties:
history:
type: array
items:
type: object
properties:
quota_internal_id:
type: string
quota_amount:
type: string
previous_amount:
type: string
changed_by:
type: string
changed_at:
type: string
format: date-time
change_type:
type: string
additionalProperties: true
reportee_id:
type: string
title: QuotaHistoryResponse
description: Historical quota changes and audit trail for a reportee.
---
id: AuditService
version: 1.0.0
name: Audit Service
summary: Captures every rio-sourced domain event from EventBridge, normalizes it, stores it in DynamoDB and archives an immutable copy to S3.
owners:
- revenue-intelligence
receives: []
sends: []
repository:
language: Python
url: 'https://github.com/rio/rio-audit-service'
---
import Footer from '@catalog/components/footer.astro';
## Service Overview
From its README: an "event-driven audit trail service for the RIO platform. Captures domain events
from EventBridge, enriches and normalizes them, stores hot records in DynamoDB, and archives
immutable copies to S3."
**This service is a pure sink. It publishes nothing.** There is not a single `put_events` call
anywhere in the repository.
## It subscribes to everything
This is the important thing to understand, and it is why the `receives` list above is empty rather
than long: the audit service does not subscribe to named events. It subscribes with a **wildcard**.
Two rules, both in `infrastructure/modules/lambda/template.yaml`:
| Rule | Pattern | Target |
|---|---|---|
| `${DeployPrefix}-audit-consumer-rule` (`:83-95`) | `source: [{ "prefix": "rio" }]` — **no `detail-type` filter** | `${DeployPrefix}-audit-consumer` → DynamoDB |
| `${DeployPrefix}-archive-consumer-rule` (`:144-156`) | `source: [{ "prefix": "rio" }]` — **no `detail-type` filter** | `${DeployPrefix}-archive-consumer` → S3 |
So **any** event whose `source` starts with `rio` is captured, automatically, with no change to this
service. A new event on a new service is audited the day it ships.
EventCatalog's `receives` field can only name specific messages, so it cannot express this. Read the
table above as the real contract: every `rio.*` event in this catalog is also received here.
## What it expects the payload to look like
Because it consumes everything, this service defines the platform's **shared event vocabulary**. Any
event that does not fit fails validation and does not make it into the audit trail.
`DomainEvent` (`shared/models.py:10-39`) is a Pydantic model with a `mode="before"` validator that
lowercases `domain`, `subdomain`, `entity_type`, `action`, `status` and `actor_type`. That is why
`rio-identity-service` can emit `status: "SUCCESS"` in uppercase and still be accepted.
The allowed values (`shared/constants.py`) are:
| Field | Allowed values |
|---|---|
| `domain` | `core`, `commit`, `learn`, `act`, `assess`, `enrich`, `notification`, `platform`, `opportunity` |
| `subdomain` | `identity`, `commit`, `quota`, `activity`, `alerts`, `ingestion` |
| `entity_type` | `user`, `role`, `opportunity`, `commit`, `quota`, `hierarchy`, `notification`, `tenant`, `crm_sync`, `etl_batch`, `external_signal`, `schema_mapping`, `glue_job` |
| `action` | 40 values, including `created`, `updated`, `deleted`, `assigned`, `submitted`, `adjusted`, `finalized`, `locked`, `stage_changed`, `deal_won`, `deal_lost`, `sync_started`, `batch_completed` |
| `status` | `success`, `failed`, `rejected`, `in_progress`, `failure` |
| `severity` | `info`, `warn`, `error` |
| `actor_type` | `user`, `system`, `admin`, `crm`, `manager` |
> **This is where the commit-service bug bites.** The malformed `QuotaAssigned` path described on the
> Commit Service page produces
> `entity_type: "commit"` with `action: "quotaassigned"`. `quotaassigned` is not in the `action` list,
> so **that event is rejected here and never reaches the audit trail.**
## How records are stored
Each accepted event becomes an `AuditRecord` (`shared/models.py:42-76`) — the original fields plus an
`audit_id`, an optional `summary`, `changed_fields`, `severity`, a `redacted` flag, the S3
`archive_key` and a `ttl_epoch`.
DynamoDB keys are built to support four lookup patterns (`shared/models.py:79-110`):
| Access pattern | Key |
|---|---|
| Everything in a tenant + domain on a day | `PK = TENANT#{t}#DOMAIN#{d}#DAY#{YYYYMMDD}` |
| History of one record | `GSI1_PK = TENANT#{t}#ENTITY#{type}#{id}` |
| Everything one person did | `GSI2_PK = TENANT#{t}#ACTOR#{actor_id}` |
| One traced request end to end | `GSI3_PK = TENANT#{t}#CORR#{correlation_id}` |
The sort key is `TS#{occurred_at}#EVT#{audit_id}`, so results come back in time order.
## Storage and retention
- **DynamoDB `${DeployPrefix}-audit-table`** — the hot store. On-demand billing, point-in-time
recovery on, 90-day TTL via `ttl_epoch`. Three GSIs, all `ProjectionType: ALL`.
- **S3 `${DeployPrefix}-archive`** — the immutable copy. AES256, versioned, moves to Glacier after
180 days, `DeletionPolicy: Retain`.
So audit data lives 90 days in DynamoDB for fast queries and indefinitely in S3 for compliance.
It also **reads** `dim_person` and `dim_tenant_role` from RDS, purely to check the caller's
`can_view_audit` permission (`api/core/dependencies.py:61-64`).
## HTTP API
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/health` | Health check |
| `GET` | `/tenants/{tenant_id}/audits` | Query the audit trail |
The query endpoint accepts `domain`, `day`, `entity_type`, `entity_id`, `actor_id`,
`correlation_id`, `action`, `time_from`, `time_to`, `limit` (1–1000, default 100), `last_key` and
`direction`. It is guarded by `validate_tenant_and_user` and `require_view_audit_permission`.
> There is **no OpenAPI spec file** in this repository, so no interactive explorer is attached to this
> service page.
Each Lambda has an SQS dead-letter queue. These are DLQs for failed invocations, not subscriptions —
nothing drains them automatically.
### Inbound and Outbound Message Flow
---
id: CommitService
version: 1.0.0
name: Commit (Revenue Planning) Service
summary: Manages hierarchical revenue forecasting, team rollups, manager adjustments, and quota assignment across fiscal periods.
owners:
- revenue-intelligence
receives: []
sends:
- id: CommitSubmitted
to:
- id: 'rio-events'
- id: CommitAdjusted
to:
- id: 'rio-events'
- id: CommitFinalized
to:
- id: 'rio-events'
- id: QuotaAssigned
to:
- id: 'rio-events'
- id: QuotaUpdated
to:
- id: 'rio-events'
entities:
- id: ForecastSubmission
- id: ForecastRevision
- id: Quota
- id: QuotaAudit
repository:
language: Python
url: 'https://github.com/rio/rio-commit-service'
---
import Footer from '@catalog/components/footer.astro';
## Service Overview
The Commit Service runs the forecasting cycle. Sales reps submit what they expect to close, managers
roll those numbers up their team and adjust them, and the service holds quota targets alongside so the
two can be compared.
From its README: it is "responsible for managing hierarchical revenue forecasting, team rollups, and
manager adjustments… across multiple fiscal periods and cadences".
Access control is hierarchy-based. A recursive SQL query walks the person tree to decide whose numbers
you are allowed to see, so a manager sees their whole subtree and a rep sees only themselves. RDS
credentials are pulled from SSM at runtime and cached for five minutes. Personal data is stored hashed
and resolved through the DynamoDB PII vault.
## It publishes, but listens to nothing
There are **no** EventBridge rules, SQS subscriptions, or event source mappings in this repo. Besides
the HTTP API, the only triggers are two cron schedules
(`infrastructure/lambda/template.yaml:102-114`):
| Schedule | Cron | Purpose |
|---|---|---|
| `AutoSubmitSchedule` | `cron(1 0 ? * MON *)` | Auto-submit forecasts reps did not submit |
| `AutoAdjustSchedule` | `cron(1 0 ? * TUE *)` | Auto-apply manager adjustments |
## Events are published transactionally
`publish_event` re-raises `ClientError` rather than swallowing it
(`api/events/eventbridge.py:172-195`). If EventBridge rejects the publish, the surrounding database
transaction rolls back. That means **you will not find a forecast row without its matching event** —
useful to know when reconciling the two. Publishing is skipped for draft submissions.
## The published payload is thinner than it looks
The service builds rich domain models — `CommitSubmittedEvent` carries `commit_amount`,
`upside_amount`, `breakdown`, `target_quarter`, `cadence_label` and more
(`api/events/schemas.py:69-140`).
But `_enrich_event_detail` (`api/events/eventbridge.py:36-96`) does **not** send those fields. It
builds a fresh audit envelope and copies across only `timestamp`, `tenant_id`, `actor_id`, `status`,
`before`, `after`, the error fields, and whichever of `quota_internal_id` / `submission_id` applies.
Everything else is dropped. On top of that, the domain models never populate `before` / `after`, so
those are always `null`.
**Practical consequence:** a consumer cannot learn the committed amount from the event. It receives
the submission id and must call the API for the numbers.
## One defect worth knowing about
**A malformed detail-type.** `api/services/quota_service.py:444` passes
`detail_type="QuotaAssigned"` — no space — while the other three call sites (`:303`, `:539`, `:886`)
correctly pass `"Quota Assigned"`. The lookup in `_DETAIL_TYPE_MAP.get()`
(`api/events/eventbridge.py:53-55`) falls through, so that bulk-create path emits an event with
`detail-type: "QuotaAssigned"`, `event_name: "rio.commit.commit.quotaassigned"`,
`entity_type: "commit"` and `action: "quotaassigned"`. None of those are valid values in the audit
service's enums, so **the audit service rejects that event** and quotas created through the bulk path
do not appear in the audit trail.
## The bus name: a false alarm, resolved
`infrastructure/template.yaml:174-176` defaults `EventBusName` to `rio-commit-events`, which is not
the shared bus. That default looks alarming, but **it is dead config — it is never used.**
`samconfig.tmpl` overrides it explicitly in all three environments:
| Environment | Value | Line |
|---|---|---|
| dev | `dev-rio-events` | `samconfig.tmpl:97` |
| qa | `qa-rio-events` | `samconfig.tmpl:198` |
| prod | `prod-rio-events` | `samconfig.tmpl:307` |
These are literal strings, and the deployspec's `envsubst` whitelist is only
`'$DEPLOY_PREFIX,$ECR_IMAGE_URI'` (`cicd/deployspec.yaml:41`), so nothing rewrites them. Every deploy
passes `--config-env ${ENVIRONMENT}`, selecting one of those three sections.
**Commit events do reach the shared bus and the audit trail in every environment.** The stale default
is still worth changing so the template does not mislead the next reader, but it is not an
operational problem.
## The scheduled Lambda cannot publish
A related gap that is real: `EventBusName` is wired into the ECS module only
(`infrastructure/template.yaml:340`). It is **not** passed to `LambdaModule` (`:257-280`), so
`${DeployPrefix}-auto-submit` receives no `EVENT_BUS_NAME` environment variable
(`lambda/template.yaml:84-100`).
That Lambda is what auto-submits forecasts on Monday and auto-adjusts on Tuesday for people who did
not act. **Those automated submissions therefore produce no events** — unlike the same action taken
through the API. If you are reconciling forecast activity against the event stream, cron-driven
submissions will be missing.
## Data stores
Raw SQL, no ORM models or migrations. Names come from `api/core/config.py:73-92`.
**Written by this service:** `fact_quota`, `fact_quota_audit`, `fact_forecast_submission`,
`fact_forecast_revision`.
**Read only:** `person`, `tenant_role`, `opportunity`, `hierarchy_revision`, `tag` in Postgres;
`fact_opportunity_history`, `fact_forecast_submission_history`, `dim_person_history` and other history
tables in ClickHouse; the DynamoDB PII vault.
## A note on this repo's own docs
`events/README.md` in the service repo describes sources `rio.commit.forecast` and `rio.commit.quota`,
and event files named `ForecastSubmitted.json` / `ForecastAdjusted.json` / `ForecastFinalized.json`.
Those files do not exist — the real ones are `CommitSubmitted.json`, `CommitAdjusted.json`,
`CommitFinalized.json` — and the code uses a single source, `rio.commit`. **The code is authoritative**
and is what this catalog records.
### Inbound and Outbound Message Flow
---
id: DataIngestionService
version: 1.0.0
name: Data Ingestion Service
summary: Scheduled pipeline that extracts 29 tables from Actian CRM365 over JDBC, vaults PII, and loads S3, ClickHouse and RDS.
owners:
- revenue-intelligence
receives: []
sends:
- id: CRMSyncStarted
to:
- id: 'rio-events'
- id: CRMSyncCompleted
to:
- id: 'rio-events'
- id: CRMSyncFailed
to:
- id: 'rio-events'
- id: ExternalSignalReceived
to:
- id: 'rio-events'
- id: ETLBatchStarted
to:
- id: 'rio-events'
- id: ETLBatchCompleted
to:
- id: 'rio-events'
- id: ETLBatchFailed
to:
- id: 'rio-events'
- id: HierarchyUpdatedByIngestion
to:
- id: 'rio-events'
repository:
language: Python
url: 'https://github.com/rio/rio-ingestion-service'
---
import Footer from '@catalog/components/footer.astro';
## Service Overview
The CRM (**Actian CRM365**) is RIO's system of record, but it is not built for analytics and the RIO
application cannot query it directly. This service copies the CRM's data — on a schedule and
incrementally — into three purpose-built stores:
- **S3 (bronze)** — a durable archive of every table.
- **ClickHouse** — the analytics warehouse behind dashboards and the Cube semantic layer.
- **RDS PostgreSQL** — the transactional store the RIO application reads.
Along the way it standardises each table, writes an HMAC-SHA256 snapshot of every PII row into a
DynamoDB vault, and emits an audit event at every stage so a run can be traced end to end.
> Note that vaulting is an **index, not a redaction**. `vault_pii_batch()` writes the token and the
> lowercased values to DynamoDB and returns the dataframe unchanged, so plaintext PII still reaches
> S3, ClickHouse and RDS.
The full step-by-step walkthrough is documented as a flow:
RIO Ingestion Service — CRM Data Sync Pipeline.
## It is not event-driven
Despite publishing eight events, this service **subscribes to no RIO event**. It is started by a clock:
| Trigger | Detail |
|---|---|
| EventBridge Scheduler cron | `cron(0 * * * ? *)` in prod, `cron(0 0/6 * * ? *)` elsewhere → Step Functions → ECS Fargate task |
| S3 object upload | `aws.s3` / `Object Created` on the **default** bus, filtered to the product-master bucket and the `reference/product_master/` prefix (`infrastructure/modules/ecs/crm-data-sync.yaml:368-390`) |
The second is the only rule in the repo, and it listens to AWS, not to RIO. See
AWS Default Event Bus.
## Two event families, two envelopes
| | Audit family | Hierarchy event |
|---|---|---|
| `source` | `rio.platform` (`dependencies/audit_events.py:36`) | `rio.glue.crm_sync` (`dependencies/constants.py:57`) |
| Events | 8 (`CRM Sync Started`, `ETL Batch Completed`, …) | 1 (`Hierarchy Updated`) |
| Payload | `AuditEventDetail` dataclass (`audit_events.py:100-136`) | business event with `actor` / `context` / `data` |
| Purpose | Observability and the audit trail | Trigger hierarchy recalculation |
### Audit event reference
All eight share `source` `rio.platform`. Enum definitions at `audit_events.py:43-51` (detail-types)
and `:54-62` (event names).
| Event | `detail-type` | `detail.event_name` | Emitter |
|---|---|---|---|
| ExternalSignalReceived | `External Signal Received` | `rio.platform.ingestion.external_signal.received` | `:351` |
| CRMSyncStarted | `CRM Sync Started` | `rio.platform.ingestion.crm_sync.started` | `:284` |
| CRMSyncCompleted | `CRM Sync Completed` | `rio.platform.ingestion.crm_sync.completed` | `:305` |
| CRMSyncFailed | `CRM Sync Failed` | `rio.platform.ingestion.crm_sync.failed` | `:327` |
| ETLBatchStarted | `ETL Batch Started` | `rio.platform.ingestion.etl_batch.started` | `:372` |
| ETLBatchCompleted | `ETL Batch Completed` | `rio.platform.ingestion.etl_batch.completed` | `:392` |
| ETLBatchFailed | `ETL Batch Failed` | `rio.platform.ingestion.etl_batch.failed` | `:414` |
## Data stores
`dependencies/table_registry.py` maps 29 CRM source tables to their targets. Every table is archived
to S3; 16 of them also land in ClickHouse and 8 in RDS. Five are archive-only.
- **ClickHouse (16 source tables)** — `dim_account`, `dim_campaign`, `dim_contract`, `dim_product`,
`dim_territory`, `fact_bpf_history`, `fact_entitlement_header`, `fact_entitlement_line`,
`fact_lead_snapshot`, `fact_opportunity_product`, `fact_opportunity_stage_history`, `fact_quote`,
`fact_quote_approval_history`, `fact_quote_detail_history`, `fact_sales_order`,
`fact_salesorder_detail_history`.
- **RDS Postgres (8 source tables → 5 target tables)** — `person`, `tenant_role`, `opportunity`,
`account_team`, `deal_team`, upserted through `staging.temp_*` staging tables. Three source tables
fan out to two Postgres tables each via `rds_destinations`.
- **DynamoDB** — watermark table (how far each table has synced per destination) and the PII vault.
## Observability
The task exports **OpenTelemetry** metrics over OTLP/HTTP when `OTEL_ENABLED=true` and
`OTEL_EXPORTER_ENDPOINT` is set (both default to on in `crm-data-sync.yaml`), flushed every 15
seconds. `init_metrics()` runs at the top of `main()` and `shutdown_metrics()` in the closing
`finally`.
| Metric | Type | Meaning | Attributes |
|---|---|---|---|
| `rio.ingestion.s3_queue_rows_sent` | Counter | Rows written to the S3 gold bucket for ClickHouse | `table_name`, `tenant_id` |
| `rio.ingestion.clickhouse_rows_received` | Counter | Rows confirmed in ClickHouse after S3Queue ingestion | `table_name`, `tenant_id` |
Comparing the two counters is how a silent S3Queue ingestion failure is caught — the audit events
cannot show it, since `ETLBatchCompleted` always reports zero rows. System-level CPU, memory, disk
and network metrics come from `SystemMetricsInstrumentor`.
## A note on this repo's own docs
`events/contracts.md` claims source `rio.glue.crm_sync` and a single detail-type `RIO Ingestion Event`
for the whole audit family. The code uses source `rio.platform` and eight distinct Title Case
detail-types. `contracts.md` also lists `action` and `status` values (`sync_started`, `SUCCESS`) that
do not match the code enums (`started`, `success`). **The code is authoritative** and is what this
catalog records.
Also note the README describes `glue-jobs/`, but the code actually lives in `jobs/crm-data-sync/` and
now runs on ECS, not Glue.
### Inbound and Outbound Message Flow
---
id: DealDeskService
version: 1.0.0
name: Deal Desk Service
summary: Manages user Google account OAuth connections via AWS Bedrock AgentCore Identity, ingests Gmail communications, and runs a 6-stage AI intelligence pipeline that turns sales email threads into deal intelligence.
owners:
- revenue-intelligence
receives:
- id: GoogleConnectionEstablished
from:
- id: 'rio-events'
- id: EmailIngestionSucceeded
from:
- id: 'rio-events'
- id: EmbeddingsBatchReady
from:
- id: 'rio-events'
sends:
- id: GoogleConnectionEstablished
to:
- id: 'rio-events'
- id: EmailSyncSucceeded
to:
- id: 'rio-events'
- id: EmailSyncFailed
to:
- id: 'rio-events'
- id: EmailIngestionSucceeded
to:
- id: 'rio-events'
- id: EmailIngestionFailed
to:
- id: 'rio-events'
- id: EmailParsingFailed
to:
- id: 'rio-events'
- id: EmailDistillationFailed
to:
- id: 'rio-events'
- id: EmailEmbeddingFailed
to:
- id: 'rio-events'
- id: EmailResolutionFailed
to:
- id: 'rio-events'
- id: EmailResolutionSummary
to:
- id: 'rio-events'
- id: EmbeddingsBatchReady
to:
- id: 'rio-events'
- id: PatternSynthesisFailed
to:
- id: 'rio-events'
repository:
language: Python
---
import Footer from '@catalog/components/footer.astro';
## Service Overview
The **Deal Desk Service** (`rio-deal-desk`, deployed as a FastAPI container on AWS ECS Fargate alongside serverless AWS Lambda and Step Functions workers) is the core processing engine for RIO's revenue intelligence capabilities.
It ingests Deal Desk team communications—focusing exclusively on email threads involving Deal Desk team email addresses (including internal quote approval requests, discount exception discussions, rep-to-analyst negotiations, and buyer exchanges)—with RIO's analytics engine. It manages per-user Google Workspace / Gmail OAuth connections, ingests email threads into encrypted S3, executes a 6-stage AI enrichment pipeline (Amazon Bedrock LLMs & Nova Pro / Titan Embeddings), and serves structured deal intelligence (MEDDPPICC scoring, pricing rationale, timelines, quote revision graphs) via REST APIs.
---
## Key Service Deliverables
| Deliverable | Description |
|---|---|
| **MEDDPPICC Scorecard** | Breakdown showing technical fit, champion strength, economic buyer access, and paper process status. |
| **Pricing Rationale** | Analysis of discount asks, competitor price pressure, and commercial trade-offs. |
| **Deal Timeline** | Chronological timeline of buyer milestones, legal reviews, and key email exchanges. |
| **Quote Revision Graph** | Tree representation of quote iterations showing historical discount escalation over time. |
| **Market Pattern Insights** | Aggregated buyer objections grouped by product line, deal size band, and geographic region. |
---
## OAuth Connection Sequence
```
POST /authorize GET /callback
Frontend ────────────────────────▶ FastAPI API ────────────────────────▶ FastAPI API
│ │ (sign HMAC state, │ (verify state,
│ │ call AgentCore) │ store in AgentCore,
│ authorization_url │ │ update DB, publish event)
│◀──────────────────────────────────┘ │
│ │
└─▶ Redirect user ▶ Google Consent ▶ AgentCore Vault ▶ Redirect Callback ──┘
│
v
EventBridge: GoogleConnectionEstablished
│
v
Step Functions: Email Backfill Pipeline
```
---
## HTTP API Surface (10 Endpoints)
The FastAPI application running on ECS Fargate exposes ten routes across three core operational groups:
### 1. Integration & Connection Lifecycle (`/activity/integrations`)
- `POST /activity/integrations/{provider}/connections/{person_id}/authorize`: Initiates per-user OAuth flow. Returns signed HMAC state and Google `authorization_url` generated via AWS Bedrock AgentCore Identity.
- `GET /activity/integrations/{provider}/callback`: OAuth callback endpoint handling Google redirect. Validates HMAC state, registers OAuth tokens in AgentCore token vault, updates Postgres `oauth_connection` table, and publishes `GoogleConnectionEstablished` event.
- `GET /activity/integrations/{provider}/connections/{person_id}`: Retrieves connection status (`connected`, `connected_at`, granted `scopes`, `error`).
- `GET /activity/integrations/google/account-summary`: Returns summarized Gmail mailbox account metadata for the authenticated user.
### 2. Opportunity Email Threads (`/activity/opportunities`)
- `GET /activity/opportunities/{id}/email-summary`: Retrieves aggregated email activity metrics and key thread indicators for a target CRM opportunity.
- `GET /activity/opportunities/{id}/threads`: Returns paginated email threads linked to an opportunity, including participant lists, message dates, and thread resolution status.
### 3. Deal Intelligence Serving (`/activity/deal-intelligence`)
- `GET /activity/deal-intelligence/{id}`: Retrieves cached AI-generated deal intelligence from Postgres `deal_intelligence` table (cached with a 24-hour TTL).
- `POST /activity/deal-intelligence/{id}/generate`: Triggers an async Lambda worker to compute fresh AI deal intelligence for an opportunity (pricing justification, MEDDPPICC extraction, timeline, quote graph).
---
## 6-Stage Serverless Intelligence Pipeline
```
+-------------------+ +-------------------+ +-------------------+
| Stage 1: Ingest | ───▶ | Stage 2: Parse | ───▶ | Stage 3: Resolve |
| Gmail -> S3/CH | | Bedrock Converse | | 3-Lane Resolution |
+-------------------+ +-------------------+ +-------------------+
│
▼
+-------------------+ +-------------------+ +-------------------+
| Stage 6: Serving | ◄─── | Stage 5: Synthesis| ◄─── | Stage 4: Distill |
| Deal Intelligence | | Canonical/Pattern | | Claude + Titan |
+-------------------+ +-------------------+ +-------------------+
```
### Stage 1 — Ingestion & Corpus (Sync Schedule & Mechanics)
- **Backfill Sync**: Triggered immediately upon receiving `GoogleConnectionEstablished` event. Executes `email_backfill.asl.json` with 2-way parallel Map concurrency. Backfills up to 90 days (`newer_than:90d` query) of historical email messages.
- **Scheduled Sync**: EventBridge Scheduler cron (`rate 1 day`) launches `email_sync.asl.json`. Fetches incremental email deltas via Gmail `historyId` cursor stored in Postgres `email_sync_state`.
- **Corpus Storage**: Raw email content is stored durably in **Amazon S3** (`${DeployPrefix}-email-corpus`), partitioned by tenant and date (`{tenant_id}/year=/month=/day=/{msg}.json`), encrypted with a dedicated per-environment **Customer Managed Key (CMK)** via Server-Side Encryption (SSE-KMS).
- **Corpus Index**: Metadata is logged to ClickHouse `dd_corpus_index` table (deduplicated on `external_id`).
- **Attachment Pipeline**: Asynchronous trigger via `EmailIngestionSucceeded` event launches `email_attachment_pipeline.asl.json` to store attachments in S3 and log metadata in Postgres `email_attachment`.
### Stage 2 — Parsing
- **Orchestration**: Scheduled via `email_parsing_pipeline.asl.json`.
- **LLM Engine**: Amazon Bedrock Converse API (inline for under 100 artifacts, Bedrock Batch for over 100).
- **3-Level Parsing Resilience**:
1. *Attempt 1*: Parse body + all attachments.
2. *Attempt 2*: If content filtering/size limits fail, retry with small attachments only (under 4MB).
3. *Attempt 3*: If still failing, fallback to body-only text extraction.
4. *Permanent Failure*: Record entry in Postgres `parsed_email_failure` and emit `EmailParsingFailed` event.
- **Output**: Parsed text saved to S3 & Postgres `parsed_email`. Idempotency tracked in DynamoDB `pipeline-idempotency`.
### Stage 3 — Attribution & Resolution
- **3a. Attribution (`email_attribution_pipeline.asl.json`)**: Reads S3 message headers and maps sender/recipient email addresses to CRM person and account IDs using `AddressResolver`. Populates ClickHouse `dd_fact_artifact`, `dd_fact_artifact_participant`, and `dd_fact_thread`.
- **3b. Resolution (`email_resolution_pipeline.asl.json`)**: Matches email threads to CRM entities using 3 evidence lanes:
- *CRM Lane*: Direct structural lookup (Account -> Opportunities, Products, Quotes, Regions).
- *LLM Lane*: Claude extracts candidate entities from text.
- *Text Lane*: Regex/heuristics matching SKUs, deal sizes, region codes.
- **Claim Fusion**: Fusion engine weighs lane confidence scores to resolve Opportunity, Product, Quote, and Region per thread.
- **Persistence**: Writes resolved threads to Postgres `thread_resolution` and ClickHouse `dd_fact_thread` / `dd_fact_thread_learning`.
### Stage 4 — Distillation & Embedding
- **Orchestration**: `email_distillation_pipeline.asl.json`.
- **LLM Processing**: Amazon Bedrock Claude extracts key business learnings, risks, and positive indicators per thread.
- **Vector Generation**: Bedrock Titan Embeddings model generates **1024-dimensional vector embeddings** for each distilled learning.
- **Output**: Written to ClickHouse `dd_fact_thread_learning` with slice tags (`product`, `region`, `deal_type`, `deal_size_band`). Emits `EmbeddingsBatchReady` event.
### Stage 5 — Knowledge Base (Canonical Learning & Pattern Synthesis)
- **5a. Canonical Learning (`canonical_learning_pipeline.asl.json`)**: Triggered by `EmbeddingsBatchReady` event. Performs Hierarchical Navigable Small World (HNSW) vector similarity search in ClickHouse to cluster near-duplicate learnings into canonical rows in `dd_canonical_learning`. Retries collapse deterministically via `uuid5(slice + learning_id)`.
- **5b. Pattern Synthesis (`pattern_synthesis_pipeline.asl.json`)**: Scheduled pipeline using Bedrock Claude to synthesize high-level business patterns per slice (`product x region x deal_type x deal_size_band`) into ClickHouse `dd_synthesized_pattern`.
### Stage 6 — Deal Intelligence Serving
- **Trigger**: Async Lambda triggered on-demand via `POST /activity/deal-intelligence/{id}/generate` or GET cache miss.
- **LLM Reasoning**: Combines CRM opportunity details, quote revision histories, and distilled email learnings using Bedrock Claude.
- **Generated Outputs**:
- *Pricing Justification*: Net ask, price drivers, discount justification.
- *MEDDPPICC Extraction*: Full methodology scoring (Metrics, Economic Buyer, Decision Criteria, Decision Process, Paper Process, Identify Pain, Champion, Competition).
- *Chronological Deal Timeline*: Visual deal milestones.
- *Quote Revision Graph*: Historical quote changes and discount iterations.
- **Caching**: Stored in Postgres `deal_intelligence` with a 24-hour cache TTL.
---
## Data Stores & Schemas
### PostgreSQL (Transactional State — RDS)
- `oauth_connection`: Per-user OAuth connection state, tokens vault reference, granted scopes.
- `email_sync_state`: Gmail `historyId` watermark cursor and backfill status per user.
- `parsed_email`: Body/attachment parse status and S3 object keys.
- `parsed_email_failure`: Attachment and message parsing failure audit logs.
- `email_attachment`: Metadata for downloaded email attachments.
- `thread_resolution`: Thread-to-CRM entity resolution outputs, risks, positive indicators.
- `deal_intelligence`: Cached daily AI deal intelligence results.
### ClickHouse (Analytics Warehouse — `dd_` Tables)
- `dd_corpus_index`: Index of all ingested raw S3 email artifacts.
- `dd_email_sync_run_log`: Audit execution logs for sync and backfill passes.
- `dd_fact_artifact`: Per-email attribution facts (sender, account, timestamp).
- `dd_fact_artifact_participant`: Email participant mappings (To, Cc, Bcc).
- `dd_fact_thread`: Rollup metrics and resolution attributes per email thread.
- `dd_fact_thread_learning`: Thread learnings with 1024-dim Titan vector embeddings.
- `dd_canonical_learning`: Clustered canonical learnings from vector similarity search.
- `dd_synthesized_pattern`: Synthesized business patterns per product/region/deal slice.
### DynamoDB & S3
- **DynamoDB (`pipeline-idempotency`)**: Stage-level idempotency lock table preventing duplicate processing across re-runs.
- **Amazon S3 (`prod-rio-email-corpus`)**: Server-Side Encrypted (SSE-KMS) bucket storing raw normalized email payloads.
---
## Observability, Event Routing & Security
### Event Sources
- `rio.activity`: Used by API endpoints, deal intelligence triggers, and pipeline failure alerts.
- `rio.core.activity`: Used exclusively by the email ingestion Lambda for audit events.
### Notification Alert Routing
Ingestion failure events append `detail.target_service = "rio.platform.notification"` into their payload. The Notification Service EventBridge rule filters on this field and `detail.event_name` to route alerts to technical notification channels.
## High-Level Service Architecture
The diagram below shows the high-level architecture — Ingestion & Corpus, Attribution & Distillation, Knowledge Base, Output, Feedback Flywheel, and Key Design Principles — for the RIO Deal Desk Service.

---
## Event Wiring (Service Message Flow)
Auto-generated view of which events this service sends and receives on the EventBridge bus.
---
id: IdentityHierarchyService
version: 1.0.0
name: Identity & Hierarchy Service
summary: Owns tenants, people, roles and the org hierarchy. Calculates level_1..level_10 hierarchy paths and publishes identity change events.
owners:
- revenue-intelligence
receives:
- id: HierarchyUpdatedByIngestion
from:
- id: 'rio-events'
- id: TenantCreated
from:
- id: 'rio-events'
sends:
- id: UserCreated
to:
- id: 'rio-events'
- id: UserUpdated
to:
- id: 'rio-events'
- id: UserDeactivated
to:
- id: 'rio-events'
- id: UserRoleAssigned
to:
- id: 'rio-events'
- id: UserRoleRemoved
to:
- id: 'rio-events'
- id: HierarchyUpdated
to:
- id: 'rio-events'
- id: TenantCreated
to:
- id: 'rio-events'
entities:
- id: Tenant
- id: Person
- id: TenantRole
- id: HierarchyRevision
- id: AccountTeam
- id: DealTeam
repository:
language: Python
url: 'https://github.com/rio/rio-identity-service'
---
import Footer from '@catalog/components/footer.astro';
## Service Overview
The Identity & Hierarchy Service is the system of record for **who a person is and where they sit in
the org chart**. Its README describes it as an "event-driven microservice for organizational identity
management and updates in the RIO platform".
It owns four things:
1. **Tenants** — the customer accounts on the platform, plus their status and subscription plan.
2. **People** — users sourced from the CRM and from identity providers.
3. **Roles** — the RBAC model, including the `can_*` permission booleans that other services read in
order to authorise a request.
4. **The hierarchy** — the manager chain, flattened into `level_1_id` … `level_10_id` and a
`hierarchy_path` on each person, so any service can answer "who reports up to this manager" with a
single query.
Hierarchy recalculation is the heaviest operation. It takes a Postgres advisory lock
(`pg_advisory_xact_lock`) to avoid deadlocks when two revisions land at once, and it treats people
whose `provisioning_source` is `MANUAL` as protected — the CRM sync will not overwrite them.
## Two different event envelopes
This service publishes on **two `source` values, with two different payload shapes**. That is easy to
trip over, so it is worth stating plainly.
| | Audit family | Hierarchy fan-out |
|---|---|---|
| `source` | `rio.core` | `rio.api.hierarchy_change` |
| Defined at | `api/core/constants.py:79` | `api/services/hierarchy_service.py:2077-2080` |
| Published by | `api/events/publisher.py:45-53` | `api/services/hierarchy_service.py:2050-2101` |
| Payload | `AuditEventDetail` (Pydantic, `api/events/models.py:88-120`) | a plain dict — `event_name`, `domain`, `version`, `actor`, `context`, `metadata`, `data` |
| Consumed by | the audit service's catch-all rule | this service's own hierarchy Lambda |
The hierarchy fan-out is deliberately shaped like the event `rio-ingestion-service` emits, so that one
Lambda rule can match both. See Hierarchy Updated.
## What it listens for
Two rules, both declared in `infrastructure/lambda/template.yaml`:
| Rule | Matches | Target |
|---|---|---|
| `${DeployPrefix}-hierarchy-updates` (`:168-179`) | `source` in `rio.glue.crm_sync`, `rio.api.hierarchy_change`; `detail-type` `Hierarchy Updated`; `detail.event_name` `rio.user.hierarchy.updated` | hierarchy recalculation Lambda |
| `${DeployPrefix}-tenant-setup` (`:210-222`) | `source` `rio.core`; `detail-type` `Tenant Created`; `detail.event_name` `rio.core.identity.tenant.created` | tenant bootstrap Lambda |
Note that the first rule listens for its **own** `rio.api.hierarchy_change` events as well as the CRM
pipeline's — the service triggers its own recalculation asynchronously.
## Data stores
All access is raw SQL through `sqlalchemy.text()`; there are no ORM models or migrations in this repo.
Table names are injected from settings (`api/core/config.py:44-51`).
- **Postgres (RDS)** — `dim_person`, `dim_tenant_role`, `dim_tenant`, `dim_subscription_plan`,
`hierarchy_revision`, `user_group`, `tenant_status`, `tag`.
- **ClickHouse** — history tables `dim_person_history`, `dim_person_role_history`,
`dim_person_hierarchy_history`, `fact_hierarchy_revision_history`; reads `dim_territory` for regions.
- **DynamoDB** — the PII vault holding `email_address`, `first_name`, `last_name`
(`api/core/constants.py:95`).
## A note on this repo's own docs
`events/contracts.md` in the service repo documents PascalCase detail-types (`UserCreated`,
`HierarchyUpdated`) and omits `Tenant Created` entirely. The running code uses space-separated Title
Case. **The code is authoritative** and is what this catalog records.
### Inbound and Outbound Message Flow
---
id: NotificationService
name: Notification Service
version: 1.0.0
summary: EventBridge-triggered service that dispatches notifications through pluggable channels (SES, Google Chat) and logs every delivery attempt to DynamoDB.
owners:
- revenue-intelligence
receives:
- id: CreateNotification
from:
- id: 'rio-events'
- id: EmailSyncFailed
from:
- id: 'rio-events'
- id: EmailIngestionFailed
from:
- id: 'rio-events'
sends:
- id: NotificationUpdated
to:
- id: 'rio-events'
- id: CreateNotification
to:
- id: 'rio-events'
- id: NotificationSent
to:
- id: 'rio-events'
- id: NotificationSentToTechnicalEmails
to:
- id: 'rio-events'
- id: NotificationSentToAlertChannels
to:
- id: 'rio-events'
repository:
language: Python
url: 'https://github.com/rio/rio-platform-notification-service'
specifications:
- type: openapi
path: openapi.yaml
name: RIO Notification API
---
import Footer from '@catalog/components/footer.astro';
## Service Overview
From its README: an "EventBridge-triggered Lambda service that dispatches notifications through
pluggable channels (SES, GOOGLE_CHAT, etc.) and logs every delivery attempt to DynamoDB."
It is really **three deployables in one repository**, and this matters because each publishes on a
different `source`:
| Component | Path | `source` it publishes on |
|---|---|---|
| FastAPI notification-centre API (ECS) | `api/` | `rio.notification.api` |
| Forecast reminder Lambda | `lambda/src/forecast_notification/` | `rio.forecast-notification` |
| Dispatch Lambda | `lambda/src/notification_service/` | `rio.platform.notification` |
## It talks to itself
The forecast Lambda publishes `Create Notification`, and the dispatch Lambda subscribes to it. So the
service is its own biggest customer:
```
forecast Lambda --(Create Notification)--> dispatch Lambda --> SES / Google Chat
```
The rule that does this is `CreateNotificationRule` (`infrastructure/template.yaml:398-405`). Note
that it filters on `detail-type` **only** — there is no `source` filter — so *any* service on the bus
can emit `Create Notification` and have it delivered. That is the intended integration point for
other teams.
## What it listens for
| Rule | Matches | Notes |
|---|---|---|
| `CreateNotificationRule` (`template.yaml:398-405`) | `detail-type` = `Create Notification` | No `source` filter — open to any publisher |
| `NotificationRule` (`template.yaml:406-416`) | `detail.target_service` = `rio.platform.notification` **and** `detail.event_name` in `rio.core.activity.emailingestionfailed`, `rio.core.activity.emailsyncfailed` | Matches on `detail` fields only — no `source`, no `detail-type` |
The second rule is unusual: it routes on payload contents rather than on the envelope. The
Activity Signal Service cooperates by stamping `target_service: "rio.platform.notification"` into the
`detail` of its failure events specifically so this rule will pick them up.
The runtime guard mirrors the rule (`lambda/src/notification_service/service.py:28-36`) with
`SUPPORTED_EVENT_NAMES` and `SUPPORTED_DETAIL_TYPES` — so both the infrastructure and the code have
to be updated together when adding a new trigger.
## Scheduled reminders
The forecast Lambda is driven by three `AWS::Scheduler` schedules, not by events
(`infrastructure/template.yaml:467-495`):
| Schedule | Cron | What it nudges |
|---|---|---|
| `${DeployPrefix}-user-reminder` | `cron(0 12 ? * THU *)` | Reps who have not submitted |
| `${DeployPrefix}-manager-reminder` | `cron(0 12 ? * FRI *)` | Managers who have not reviewed |
| `${DeployPrefix}-manager-adjustment-reminder` | `cron(0 16 ? * FRI *)` | Managers who have not adjusted |
## HTTP API
| Method | Path | Purpose |
|---|---|---|
| `GET` | `/health` | Health check, also verifies the DynamoDB inbox table |
| `GET` | `/tenants/{tenant_id}/alerts` | List a user's alerts, paginated |
| `PATCH` | `/tenants/{tenant_id}/alerts/{notification_id}` | Mark read / unread — emits `Notification Updated` |
Both alert routes are proxied through `rio-app-layer` and consumed by `rio-ui`.
## Data stores
DynamoDB only — no relational tables are owned here.
- **`${DeployPrefix}-log`** (`template.yaml:234-251`) — one row per delivery attempt. Key
`notification_id` + `created_at`, with a TTL on `ttl`.
- **`${DeployPrefix}-notification-inbox`** (`template.yaml:256+`) — backs the in-app notification
centre. Key `pk`/`sk`, with three GSIs: `entity-index`, `correlation-index`,
`status-severity-index`.
It also **reads** the RDS tables `dim_person` and `fact_forecast` to work out who to remind, but does
not own them.
## Failure handling
Both Lambdas have their own dead-letter queue — `${DeployPrefix}-notification-dlq` (`:361`) and
`${DeployPrefix}-forecast-notification-dlq` (`:422`). These are DLQs, not subscriptions; nothing
reads from them automatically.
### Inbound and Outbound Message Flow
---
id: OpportunityService
version: 1.0.0
name: Opportunity Service
summary: Read-only API serving sales opportunities with multi-tenant isolation and four-way hierarchy/RBAC visibility rules.
owners:
- revenue-intelligence
receives: []
sends: []
entities:
- id: Opportunity
- id: OpportunityProduct
- id: OpportunityHistory
- id: OpportunityStageHistory
repository:
language: Python
url: 'https://github.com/rio/rio-opportunity-service'
---
import Footer from '@catalog/components/footer.astro';
## Service Overview
From its README: "a high-performance FastAPI microservice responsible for managing and serving sales
opportunities. It enforces strict multi-tenant isolation and complex visibility rules based on the RIO
Hierarchy and RBAC models."
In practice it is a **read API**. Every one of its 15 routes is a `GET`. Opportunity records
themselves are written by the
Data Ingestion Service from the
CRM, not by this service.
## Its real job: deciding who can see what
The interesting logic here is visibility. A request is allowed to see an opportunity if **any** of
four conditions holds:
1. **Ownership** — you own the deal.
2. **Deal team membership** — you are on the deal team.
3. **Hierarchy subtree** — you have `can_view_subtree` and the owner sits under you, matched against
`level_1_id` … `level_10_id`.
4. **Regional data** — you have `can_view_region_data` and the deal's territory matches yours.
These are combined into one SQL `OR` block so visibility is resolved in the database rather than by
filtering in the application.
## It listens to nothing
No EventBridge rules, SQS queues or event source mappings. The only non-HTTP trigger is a nightly
cron: `${DeployPrefix}-opportunity-history-daily-sync`, `cron(0 0 * * ? *)` UTC
(`infrastructure/template.yaml:297-309`).
## Data stores
**Written:** ClickHouse `fact_opportunity_history` only — a daily snapshot written by the history-sync
Lambda.
**Read only:** Postgres `person`, `tenant_role`, `tenant`, `opportunity`, `deal_team`; ClickHouse
`dim_opportunity`, `dim_person_flat`, `dim_account`, `fact_opportunity_product`, `dim_product`,
`dim_territory`, `fact_quote`, `fact_bpf_history` and related detail tables; the DynamoDB PII vault.
> There is **no OpenAPI spec file** in this repository, so no interactive explorer is attached here.
### Inbound and Outbound Message Flow
---
id: PlatformInfrastructure
name: Platform Infrastructure
version: 1.0.0
summary: Owns the shared foundation every RIO service depends on - the EventBridge bus, RDS PostgreSQL, Valkey cache, the ECS cluster and per-service credentials.
owners:
- revenue-intelligence
repository:
language: YAML
url: 'https://github.com/rio/rio-infra'
---
import Footer from '@catalog/components/footer.astro';
## Service Overview
`rio-infra` is not an application — it is the CloudFormation stack that creates the things every
other service assumes already exist. It is listed as a service here because **it owns the event bus**,
and that ownership is otherwise invisible in the catalog.
## It creates the one and only event bus
The RIO Events Bus is declared here, once:
```yaml
RioEventBus: # infrastructure/template.yaml:328-331
Name: !Sub "${Environment}-rio-events"
```
and exported as the stack output `RioEventBusName` (`template.yaml:508`).
Every other service stack takes the bus name as an `EventBusName` parameter. **No repository in the
platform declares a second `AWS::Events::EventBus`** — so if you are wondering whether some service
has its own private bus, it does not.
> One template *default* looks like an exception but is not: `rio-commit-service` defaults its
> `EventBusName` parameter to `rio-commit-events`. That default is dead config — `samconfig.tmpl`
> overrides it to `{env}-rio-events` in dev, qa and prod alike. Every service really does use this
> one bus. See Commit Service.
## Its own rules listen to AWS, not to RIO
There are **no rules on `RioEventBus` in this repository**, and no `rio.*` source or Title Case
detail-type appears anywhere in it. The bus is created here and used elsewhere. Its own three rules
target the AWS `default` bus or a schedule:
| Rule | Bus | Pattern | Target |
|---|---|---|---|
| `RioEcsEventCaptureRule` (`template.yaml:361-372`) | `default` | `source: aws.ecs` (all detail-types) | CloudWatch Logs, 7-day retention |
| `RDSEventRule` (`template.yaml:401-419`) | `default` | `source: aws.rds`, `detail-type: RDS DB Instance Event`, filtered to this instance and the availability / configuration change / failover / recovery / maintenance categories | `${DeployPrefix}-update-tg-ip` Lambda |
| `ScheduledHealthCheck` (`template.yaml:442-448`) | — | `rate(5 minutes)` | `${DeployPrefix}-ch-health-monitor` Lambda |
The RDS rule exists to solve a specific problem: RDS endpoints resolve to IPs that change on failover,
so a Lambda re-registers the current IP into a load-balancer target group whenever RDS reports an
event.
## What else it provisions
- **RDS** — PostgreSQL 18.3 with a custom parameter group, plus an RDS Proxy and target group.
- **ElastiCache** — Valkey 8.2, encrypted at rest and in transit.
- **ECS** — the cluster, named `${Environment}-rio`.
- **Secrets Manager** — nine secrets: proxy read-only and read-write, plus dedicated DB credentials
for `rio_identity_service`, `rio_commit_service`, `rio_audit_service`,
`rio_platform_notification_service`, `rio_ingestion_service`, `rio_opportunity_service` and
`rio_activity_service`. Note that last one — the credentials are named for the *activity* service,
which is another sign the `rio-deal-desk` repo is really the activity service.
- **SSM Parameter Store** — SecureStrings for the Valkey auth token, DB passwords, the ClickHouse
users, a security salt, the Actian master password, the activity OAuth state secret and the Google
Chat webhook.
- **S3** — `${DeployPrefix}-config-bucket`, all public access blocked.
- **CloudWatch Logs** — the ECS event log group plus a resource policy letting EventBridge write to it.
Outside CloudFormation, a Helmfile deploys **ClickHouse** (Bitnami chart, 1 shard / 1 replica, 100 Gi)
and an HAProxy `tcp-forwarder` exposing Postgres 5432 and Valkey 6379 into the EKS `rio` namespace.
## What it does not have
Worth stating explicitly, because these are things people expect to find in an infra repo:
- **No SQS queues and no DLQs.** The only DLQs in the platform belong to
`rio-platform-notification-service`.
- **No EventBridge Schema Registry**, no archives, no replay configuration.
- **No event naming-convention doc.** Those rules live in this catalog repo, at
`.claude/rules/eventbridge.md`.
---
id: RioWebApp
name: RIO Web App
version: 1.0.0
summary: The React + TypeScript frontend. Consumes every backend service through the single App Layer gateway, authenticating with Cognito OIDC.
owners:
- revenue-intelligence
repository:
language: TypeScript
url: 'https://github.com/rio/rio-ui'
---
import Footer from '@catalog/components/footer.astro';
## Service Overview
The user-facing application. React 18 + TypeScript on Vite 6, with TailwindCSS for styling and
ApexCharts for visualisation.
It is included in this catalog because it is the platform's **only human-facing consumer** — knowing
which endpoints it actually calls tells you which parts of the backend are genuinely in use.
Notably, it is built as a **Module Federation remote** (`@originjs/vite-plugin-federation`), exposed
as `rioUi` with a `./bootstrap` entry and a default base path of `/rio/`. It is designed to be mounted
inside a host shell rather than served standalone.
## One base URL, one front door
Every request goes through a single environment variable, `VITE_API_BASE_URL` (`src/lib/env.ts:31`),
pointing at the Application Layer Service.
`src/lib/httpClient.ts` is the only client: it attaches `Authorization: Bearer ` and
`X-User-Id`, raises a typed `ApiError`, and redirects to `/auth/login` on a 401.
There are **no per-service hostnames anywhere in `src/`**, which independently confirms that all
traffic is funnelled through the gateway.
Authentication is Cognito via `react-oidc-context`, matching the Lambda authorizer on the gateway side.
## Which service each screen actually talks to
| Frontend module | Endpoints | Backing service |
|---|---|---|
| `features/auth/authService.ts` | `/users/tenants`, `/tenants/{t}/users/{u}` | Identity |
| `features/admin/api/usersService.ts` | `/tenants/{t}/users` | Identity |
| `features/admin/api/rolesService.ts` | `/tenants/{t}/roles` | Identity |
| `features/admin/api/userGroupsService.ts` | `/tenants/{t}/user-groups` | Identity |
| `features/admin/api/hierarchyService.ts` | `/tenants/{t}/hierarchy`, `.../actions`, `.../nodes/{n}` | Identity |
| `features/admin/api/tagsService.ts` | `/tenants/{t}/tags` | Identity |
| `features/admin/api/regionsService.ts` | `/tenants/{t}/regions` | Identity |
| `features/admin/api/timezonesService.ts` | `/tenants/{t}/timezones` | Identity |
| `features/admin/api/auditService.ts` | `/tenants/{t}/audits` | Audit |
| `features/forecast/api/forecastService.ts` | `/tenants/{t}/forecast/*` | Commit |
| `features/quota/api/quotaService.ts` | `/tenants/{t}/quotas*` | Commit |
| `features/forecast/api/opportunityService.ts` | `/tenants/{t}/opportunities*` | Opportunity |
| `features/deal-desk/api/dealDeskBriefService.ts` | `.../quotes*`, `.../sales-team`, `.../install-base`, `/products/discount-benchmarks`, `/activity/opportunities/{id}/email-summary`, `.../threads` | Opportunity + Activity Signal |
## Two things worth knowing
**One module bypasses the shared client.** `features/admin/api/auditService.ts` builds its URL by
hand (`${env.apiBaseUrl}/tenants/${tenantId}/audits…`, line 137) and calls raw `fetch` (line 153)
instead of going through `httpClient`. So it does not get the shared 401-redirect or `ApiError`
handling that every other module gets.
**Several screens are still fixture-driven.** `src/mocks/fixtures/` and `src/mocks/handlers/` supply
mock data for the dashboard, assess, deal desk, exec dashboard, coverage-behind-call, admin console
and chart surfaces. Those pages render from fixtures, not from live APIs — so "the UI shows it" is
not evidence that an endpoint exists.
## It uses no events
The web app has no EventBridge, WebSocket, SSE or GraphQL client. All communication is request /
response over HTTPS through the gateway. It also does not call Cube or the MCP server directly.
---
id: Assess
name: Assess
summary: Evaluate deal health, pipeline quality, and risks via CRM telemetry and AI-validation.
version: 1.0.0
owners:
- revenue-intelligence
services:
- id: OpportunityService
---
import Footer from '@catalog/components/footer.astro';
The **Assess** domain is a core architectural pillar of the RIO platform. It owns everything related
to evaluating deal health, tracking pipeline risk, and serving sales opportunity data to reps and
managers.
## Services
- Opportunity Service — the
read-only API that enforces multi-tenant isolation and four-way hierarchy/RBAC visibility rules
over sales opportunities sourced from the CRM.
## Domain Architecture
---
id: Commit
name: Commit
summary: Manage forecasting cycles, user quota assignments, and manager adjustment workflows.
version: 1.0.0
owners:
- revenue-intelligence
services:
- id: CommitService
---
import Footer from '@catalog/components/footer.astro';
The **Commit** domain is a core architectural pillar of the RIO platform. It owns the full
revenue forecasting lifecycle — from rep submissions to manager rollups, quota assignment, and
auto-submit cron jobs.
## Services
- Commit Service — manages
hierarchical revenue forecasting, team rollups, manager adjustments, and quota assignment across
fiscal periods. Publishes events transactionally so the audit trail is always consistent with the
database state.
## Domain Architecture
---
id: CoreSharedServices
name: Core Shared Services
summary: Common backend business logic services used by all pillars — identity, org hierarchy, RBAC, and tenant management.
version: 1.0.0
owners:
- revenue-intelligence
services:
- id: IdentityHierarchyService
---
import Footer from '@catalog/components/footer.astro';
The **Core Shared Services** domain is a foundational pillar of the RIO platform. It owns the
system of record for who a person is, which tenant they belong to, what roles they hold, and
where they sit in the org chart. Every other domain reads identity and hierarchy data from here.
## Services
- Identity & Hierarchy Service —
owns tenants, people, RBAC roles, and the flattened `level_1..level_10` org hierarchy. Publishes
identity change events consumed by the audit trail and downstream services.
## Related Domains
- Deal Desk Domain — reads person and
tenant identity to resolve email thread ownership and deal team membership.
## Domain Architecture
---
id: DealDesk
name: Deal Desk
summary: Automated sales intelligence, email activity processing, MEDDPPICC deal scoring, and pricing justification engine for deal desk and revenue teams.
version: 1.0.0
owners:
- revenue-intelligence
services:
- id: DealDeskService
---
import Footer from '@catalog/components/footer.astro';
The **Deal Desk** domain is the revenue intelligence engine of the RIO platform. It turns unstructured sales communications—including internal deal desk approval requests, rep-to-analyst quote negotiations, and external buyer email exchanges—into structured, actionable deal intelligence for sales managers, deal desk analysts, pricing teams, and revenue leaders.
---
## Overview
In enterprise sales organizations, critical deal context is split across internal deal desk conversations (discount exception requests, custom contract terms, margin reviews) and external buyer communications (budget pushbacks, competitor dynamics, decision process changes). When sales reps request pricing approvals, deal desk teams need instant, complete visibility into both the internal request justification and the external buyer qualification state.
Deal Desk bridges this gap by automatically ingesting rep and deal desk communications, attributing them to CRM opportunities, scoring deal qualification, tracking quote iterations, and generating AI-powered decision briefs for rapid, data-backed approvals.
---
## Key Capabilities
### 1. Communication Attribution & Ingestion
- Connects securely to sales rep and deal desk mailboxes (Google Workspace / Gmail) with per-user OAuth privacy.
- Automatically links incoming and outgoing message threads to corresponding CRM Accounts, Opportunities, Products, and Quotes.
### 2. MEDDPPICC Qualification Scoring
Computes objective health scoring across all 8 dimensions of the MEDDPPICC sales framework:
- **Metrics (M)**: Quantifiable business ROI expected by the buyer.
- **Economic Buyer (E)**: Identification and engagement level of the ultimate budget owner.
- **Decision Criteria (D)**: Technical, commercial, and operational requirements.
- **Decision Process (D)**: Formal evaluation and approval workflow.
- **Paper Process (P)**: Legal, procurement, and contracting steps.
- **Identify Pain (P)**: Core customer pain points driving the deal.
- **Champion (C)**: Internal advocate driving consensus for the solution.
- **Competition (C)**: Threat level and positioning against competing vendors or status quo.
### 3. Pricing & Discount Rationale
- Reconstructs **Quote Revision Graphs** tracking price changes and discount escalation over time.
- Extracts explicit price drivers, competitor pressure points, and value asks from email exchanges.
- Provides deal desk analysts with clear "Why the Pricing?" context before approving exceptions.
### 4. Visual Deal Timelines & Risk Detection
- Constructs chronological timelines of key milestones, contract discussions, and stakeholder introductions.
- Flags early warning indicators such as procurement delays, executive ghosting, or sudden budget constraints.
### 5. Cross-Deal Intelligence & Pattern Synthesis
- Aggregates insights across deal cohorts (`Product x Region x Deal Type x Deal Size Band`).
---
## Domain Concepts & Glossary
| Term | Definition & Purpose |
|---|---|
| **MEDDPPICC** | Standard enterprise sales qualification framework scoring 8 dimensions: **M**etrics, **E**conomic Buyer, **D**ecision Criteria, **D**ecision Process, **P**aper Process, **P**ain, **C**hampion, and **C**ompetition. |
| **Quote Revision Graph** | A historical visual tree mapping quote iterations, price changes, and discount escalation over the lifecycle of an opportunity. |
| **Distilled Learning** | An individual structured insight (e.g., pricing pushback, competitor mention, decision blocker) extracted from a specific email thread by AI models. |
| **Canonical Learning** | A consolidated insight generated by clustering near-duplicate thread learnings across multiple deals using vector similarity search. |
| **Pattern Synthesis** | High-level macro insights synthesized across deal cohorts (`Product x Region x Deal Type x Deal Size Band`) to reveal broader market and discounting trends. |
---
## User & Stakeholder Impact
- **Deal Desk Analysts**: Evaluate pricing requests quickly with structured deal summaries instead of reading through long email threads.
- **Sales AE & Managers**: Identify hidden deal risks and MEDDPPICC gaps early in the sales cycle.
- **Finance & Pricing Teams**: Maintain discounting governance with audited quote histories and documented buyer rationale.
- **Executive Leadership**: Gain clear visibility into pipeline health and market feedback across the revenue organization.
---
## Services & Architecture
- Deal Desk Service — The primary service managing OAuth connections, Gmail ingestion, the 6-stage AI intelligence pipeline, and serving Deal Intelligence APIs.
### Architecture Highlights
- **Targeted Deal Desk Email Ingestion**: Specifically ingests and processes email threads involving **Deal Desk team email addresses and aliases**, prioritizing rep-to-deal-desk discount exception requests, quote revisions, pricing approvals, and contract negotiations over general sales inbox noise.
- **Gmail OAuth Security Vault**: Securely connects Google Workspace / Gmail user accounts through AWS Bedrock AgentCore Identity token vault without exposing raw user credentials.
- **Serverless Multi-Stage AI Pipeline**: 6 Step Functions orchestrated stages utilizing Amazon Bedrock foundation models for entity resolution, MEDDPPICC extraction, quote revision tracking, and vector embeddings (Amazon Bedrock Nova Pro / Titan).
- **Dual Analytics & Transactional Data Stores**: Durable raw email corpus in SSE-KMS encrypted Amazon S3, analytical facts, thread learnings & high-dimensional vector embeddings in ClickHouse, and transactional state with 24h cached deal intelligence in PostgreSQL.
- **Privacy & Signal Filtering**: Filters out non-deal operational noise, isolating thread communications tied directly to CRM Accounts, Opportunities, Products, and Quotes.
---
## Domain Event Wiring
The graph below shows how the **Deal Desk Service** connects to other services via EventBridge events.
For the full low-level architecture diagram (all pipeline stages, data stores, and AI/ML services),
open the Deal Desk Service page.
---
id: PlatformServices
name: Platform Services
summary: Cross-cutting backend platform services supporting scale, security, observability, and data ingestion.
version: 1.0.0
owners:
- revenue-intelligence
services:
- id: AppLayerService
- id: PlatformInfrastructure
- id: AnalyticsSemanticLayer
- id: RioWebApp
- id: AuditService
- id: DataIngestionService
- id: NotificationService
---
import Footer from '@catalog/components/footer.astro';
The **Platform Services** domain is the foundational pillar of the RIO platform. It owns every
cross-cutting capability that all business domains depend on — the API gateway, shared
infrastructure, analytics query layer, CRM data ingestion, audit trail, notifications, and the
frontend application.
## Services
### API & Frontend
- Application Layer Service — the
single API Gateway front door. Validates Cognito JWTs and proxies 41 routes over a VPC Link to
all backend services.
- RIO Web App — the React + TypeScript
frontend, the platform's only human-facing consumer.
### Infrastructure
- Platform Infrastructure —
provisions the EventBridge bus, RDS PostgreSQL, Valkey cache, the ECS cluster, and per-service
IAM credentials.
- Analytics Semantic Layer (Cube) —
the governed query vocabulary over ClickHouse — 30 cubes with named measures and dimensions, plus
an MCP server for AI assistants.
### Data & Observability
- Data Ingestion Service —
scheduled pipeline that extracts 29 tables from Actian CRM365 over JDBC, vaults PII, and
loads S3, ClickHouse, and RDS.
- Audit Service — captures every
`rio-sourced` domain event from EventBridge, normalizes it, stores it in DynamoDB, and archives
an immutable copy to S3.
- Notification Service —
EventBridge-triggered dispatcher that sends alerts through SES and Google Chat, and logs every
delivery attempt to DynamoDB.
## Domain Architecture
---
id: revenue-intelligence
name: Revenue Intelligence Team
summary: Handles all domains, services, events, and schemas across the RIO Revenue Intelligence platform.
email: dev@rio.ai
members:
- rafid-o
- jislin-anna-thomas
- shehzad-ibrahim
- shipranshi-kesri
---
## Overview
The **Revenue Intelligence Team** is the primary engineering and product team responsible for building and maintaining the RIO platform, including data pipelines, analytical schemas, machine learning predictions, forecasting models, and application gateway layers.
---
id: jislin-anna-thomas
name: Jislin Anna Thomas
role: Software Engineer
summary: Member of the Revenue Intelligence Team.
---
Member of the Revenue Intelligence Team at RIO.
---
id: rafid-o
name: Rafid O
role: Software Engineer
summary: Member of the Revenue Intelligence Team.
---
Member of the Revenue Intelligence Team at RIO.
---
id: shehzad-ibrahim
name: Shehzad Ibrahim
role: Software Engineer
summary: Member of the Revenue Intelligence Team.
---
Member of the Revenue Intelligence Team at RIO.
---
id: shipranshi-kesri
name: Shipranshi Kesri
role: Software Engineer
summary: Member of the Revenue Intelligence Team.
---
Member of the Revenue Intelligence Team at RIO.
---
id: AccountTeam
name: AccountTeam
version: 1.0.0
summary: Database model representing AccountTeam records.
owners:
- revenue-intelligence
properties:
- name: account_team_internal_id
type: UUID
required: true
description: 'Production rds field: account_team_internal_id (UUID)'
- name: tenant_id
type: UUID
required: true
description: 'Production rds field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: source_system
type: TEXT
required: true
description: 'Production rds field: source_system (TEXT)'
- name: source_account_id
type: TEXT
required: true
description: 'Production rds field: source_account_id (TEXT)'
- name: source_person_id
type: TEXT
required: true
description: 'Production rds field: source_person_id (TEXT)'
- name: snapshot_date
type: DATE
required: true
description: 'Production rds field: snapshot_date (DATE)'
- name: team_role
type: TEXT
required: true
description: Primary | Overlay | Influenced
- name: tenant_role_code
type: TEXT
required: true
description: AE | SE | Manager | Executive
- name: product_family
type: TEXT
required: true
description: 'Production rds field: product_family (TEXT)'
- name: status
type: BOOLEAN
required: true
description: 'Production rds field: status (BOOLEAN)'
- name: created_by
type: UUID
required: false
description: 'Production rds field: created_by (UUID)'
- name: created_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: created_at (TIMESTAMPTZ)'
- name: updated_by
type: UUID
required: false
description: 'Production rds field: updated_by (UUID)'
- name: updated_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: updated_at (TIMESTAMPTZ)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **AccountTeam** records stored in the relational database.
---
id: DealTeam
name: DealTeam
version: 1.0.0
summary: Database model representing DealTeam records.
owners:
- revenue-intelligence
properties:
- name: team_member_internal_id
type: UUID
required: true
description: 'Production rds field: team_member_internal_id (UUID)'
- name: tenant_id
type: UUID
required: true
description: 'Production rds field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: source_system
type: TEXT
required: true
description: 'Production rds field: source_system (TEXT)'
- name: source_opportunity_id
type: TEXT
required: true
description: 'Production rds field: source_opportunity_id (TEXT)'
- name: source_person_id
type: TEXT
required: true
description: 'Production rds field: source_person_id (TEXT)'
- name: snapshot_date
type: DATE
required: true
description: 'Production rds field: snapshot_date (DATE)'
- name: hierarchy_type
type: TEXT
required: true
description: 'Production rds field: hierarchy_type (TEXT)'
- name: team_role
type: TEXT
required: true
description: Primary | Overlay | Influenced
- name: tenant_role_code
type: TEXT
required: true
description: AE | SE | Manager | Executive
- name: product_family
type: TEXT
required: true
description: 'Production rds field: product_family (TEXT)'
- name: created_by
type: UUID
required: false
description: 'Production rds field: created_by (UUID)'
- name: created_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: created_at (TIMESTAMPTZ)'
- name: updated_by
type: UUID
required: false
description: 'Production rds field: updated_by (UUID)'
- name: updated_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: updated_at (TIMESTAMPTZ)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **DealTeam** records stored in the relational database.
---
id: ForecastRevision
name: ForecastRevision
version: 1.0.0
summary: Database model representing ForecastRevision records.
owners:
- revenue-intelligence
properties:
- name: revision_id
type: UUID
required: true
description: 'Production rds field: revision_id (UUID)'
- name: target_quarter
type: TEXT
required: true
description: 'Production rds field: target_quarter (TEXT)'
- name: submission_id
type: UUID
required: true
description: 'Production rds field: submission_id (UUID)'
- name: tenant_id
type: UUID
required: true
description: 'Production rds field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: actor_person_id
type: UUID
required: false
description: Exactly who clicked the button (Rep, L1, L2, etc.)
references: Person
referencesIdentifier: person_internal_id
relationType: hasOne
- name: actor_type
type: TEXT
required: true
description: 'Production rds field: actor_type (TEXT)'
- name: action_type
type: TEXT
required: true
description: 'Production rds field: action_type (TEXT)'
- name: action_date
type: TIMESTAMPTZ
required: true
description: 'Production rds field: action_date (TIMESTAMPTZ)'
- name: commit_amount
type: NUMERIC(18,4)
required: true
description: 'Production rds field: commit_amount (NUMERIC(18,4))'
- name: upside_amount
type: NUMERIC(18,4)
required: true
description: 'Production rds field: upside_amount (NUMERIC(18,4))'
- name: breakdown
type: JSONB
required: true
description: Snapshot of deals included in this specific action
- name: notes
type: TEXT
required: true
description: 'Production rds field: notes (TEXT)'
- name: created_by
type: UUID
required: false
description: 'Production rds field: created_by (UUID)'
- name: created_at
type: TIMESTAMPTZ
required: true
description: 'Production rds field: created_at (TIMESTAMPTZ)'
- name: updated_by
type: UUID
required: false
description: 'Production rds field: updated_by (UUID)'
- name: updated_at
type: TIMESTAMPTZ
required: true
description: 'Production rds field: updated_at (TIMESTAMPTZ)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **ForecastRevision** records stored in the relational database.
---
id: ForecastSubmission
name: ForecastSubmission
version: 1.0.0
summary: Database model representing ForecastSubmission records.
owners:
- revenue-intelligence
properties:
- name: submission_id
type: UUID
required: true
description: 'Production rds field: submission_id (UUID)'
- name: tenant_id
type: UUID
required: true
description: 'Production rds field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: owner_person_id
type: UUID
required: true
description: The Node Owner (Rep, L1 Manager, L2 Manager)
references: Person
referencesIdentifier: person_internal_id
relationType: hasOne
- name: target_quarter
type: TEXT
required: true
description: e.g., 'FY26-Q2'
- name: submission_cadence
type: TEXT
required: true
description: e.g., 'WEEKLY' or 'MONTHLY'
- name: cadence_label
type: TEXT
required: true
description: e.g., 'Week 3'
- name: revenue_type
type: TEXT
required: true
description: e.g., 'New', 'Renewal'
- name: current_status
type: TEXT
required: true
description: 'Production rds field: current_status (TEXT)'
- name: direct_commit
type: NUMERIC(18,4)
required: true
description: Pipeline from deals they personally own
- name: rollup_commit
type: NUMERIC(18,4)
required: true
description: Live mathematical sum of their team's submissions
- name: user_submitted_commit
type: NUMERIC(18,4)
required: false
description: What this owner submitted to their boss
- name: user_submitted_upside
type: NUMERIC(18,4)
required: false
description: 'Production rds field: user_submitted_upside (NUMERIC(18,4))'
- name: user_submitted_breakdown
type: JSONB
required: true
description: Exact deals they included
- name: user_submission_note
type: TEXT
required: false
description: 'Production rds field: user_submission_note (TEXT)'
- name: manager_adjusted_commit
type: NUMERIC(18,4)
required: false
description: Overridden amount by their boss
- name: manager_adjusted_breakdown
type: JSONB
required: false
description: Deals the boss added/removed (For UI Diffing)
- name: latest_adjuster_id
type: UUID
required: false
description: ID of the boss who made the adjustment
- name: latest_adjustment_note
type: TEXT
required: false
description: Boss's feedback note
- name: created_by
type: UUID
required: false
description: 'Production rds field: created_by (UUID)'
- name: created_at
type: TIMESTAMPTZ
required: true
description: 'Production rds field: created_at (TIMESTAMPTZ)'
- name: updated_by
type: UUID
required: false
description: 'Production rds field: updated_by (UUID)'
- name: updated_at
type: TIMESTAMPTZ
required: true
description: 'Production rds field: updated_at (TIMESTAMPTZ)'
- name: committed_by
type: TEXT
required: true
description: 'Production rds field: committed_by (TEXT)'
- name: adjusted_by
type: TEXT
required: false
description: 'Production rds field: adjusted_by (TEXT)'
- name: submitted_to_manager_id
type: UUID
required: false
description: 'Production rds field: submitted_to_manager_id (UUID)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **ForecastSubmission** records stored in the relational database.
---
id: HierarchyRevision
name: HierarchyRevision
version: 1.0.0
summary: Database model representing HierarchyRevision records.
owners:
- revenue-intelligence
properties:
- name: revision_id
type: UUID
required: true
description: 'Production rds field: revision_id (UUID)'
- name: tenant_id
type: UUID
required: true
description: 'Production rds field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: author_person_id
type: UUID
required: true
description: 'Production rds field: author_person_id (UUID)'
references: Person
referencesIdentifier: person_internal_id
relationType: hasOne
- name: parent_revision_id
type: UUID
required: false
description: 'Production rds field: parent_revision_id (UUID)'
- name: hierarchy_payload
type: JSONB
required: true
description: 'Production rds field: hierarchy_payload (JSONB)'
- name: hierarchy_type
type: TEXT
required: true
description: 'Production rds field: hierarchy_type (TEXT)'
- name: user_changes
type: JSONB
required: true
description: 'Production rds field: user_changes (JSONB)'
- name: node_tags
type: JSONB
required: true
description: 'Production rds field: node_tags (JSONB)'
- name: required_approval_count
type: INT
required: true
description: 'Production rds field: required_approval_count (INT)'
- name: current_approval_count
type: INT
required: true
description: 'Production rds field: current_approval_count (INT)'
- name: approved_by_users
type: TEXT[]
required: true
description: 'Production rds field: approved_by_users (TEXT[])'
- name: pending_approvers
type: TEXT[]
required: true
description: 'Production rds field: pending_approvers (TEXT[])'
- name: status
type: approval_status_enum
required: true
description: 'Production rds field: status (approval_status_enum)'
- name: is_current
type: BOOLEAN
required: true
description: 'Production rds field: is_current (BOOLEAN)'
- name: notes
type: TEXT
required: true
description: 'Production rds field: notes (TEXT)'
- name: created_at
type: TIMESTAMPTZ
required: true
description: 'Production rds field: created_at (TIMESTAMPTZ)'
- name: updated_at
type: TIMESTAMPTZ
required: true
description: 'Production rds field: updated_at (TIMESTAMPTZ)'
- name: expires_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: expires_at (TIMESTAMPTZ)'
- name: created_by
type: UUID
required: false
description: 'Production rds field: created_by (UUID)'
- name: updated_by
type: UUID
required: false
description: 'Production rds field: updated_by (UUID)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **HierarchyRevision** records stored in the relational database.
---
id: Opportunity
name: Opportunity
version: 1.0.0
summary: Database model representing Opportunity records.
owners:
- revenue-intelligence
properties:
- name: opportunity_internal_id
type: UUID
required: true
description: 'Production rds field: opportunity_internal_id (UUID)'
- name: tenant_id
type: UUID
required: true
description: 'Production rds field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: source_system
type: TEXT
required: true
description: 'Production rds field: source_system (TEXT)'
- name: source_opportunity_id
type: TEXT
required: true
description: 'Production rds field: source_opportunity_id (TEXT)'
- name: source_account_id
type: TEXT
required: true
description: 'Production rds field: source_account_id (TEXT)'
- name: source_owner_id
type: TEXT
required: true
description: 'Production rds field: source_owner_id (TEXT)'
- name: originating_lead_id
type: TEXT
required: true
description: 'Production rds field: originating_lead_id (TEXT)'
- name: opportunity_name
type: TEXT
required: true
description: 'Production rds field: opportunity_name (TEXT)'
- name: source_opportunity_type
type: TEXT
required: true
description: 'Production rds field: source_opportunity_type (TEXT)'
- name: standard_opportunity_type
type: TEXT
required: true
description: 'Production rds field: standard_opportunity_type (TEXT)'
- name: source_sales_stage
type: TEXT
required: true
description: 'Production rds field: source_sales_stage (TEXT)'
- name: standard_sales_stage
type: TEXT
required: true
description: 'Production rds field: standard_sales_stage (TEXT)'
- name: source_forecast_category
type: TEXT
required: true
description: 'Production rds field: source_forecast_category (TEXT)'
- name: standard_forecast_category
type: TEXT
required: true
description: 'Production rds field: standard_forecast_category (TEXT)'
- name: estimated_value
type: NUMERIC(18,4)
required: true
description: 'Production rds field: estimated_value (NUMERIC(18,4))'
- name: estimated_value_base
type: NUMERIC(18,4)
required: true
description: 'Production rds field: estimated_value_base (NUMERIC(18,4))'
- name: acv_amount
type: NUMERIC(18,4)
required: true
description: 'Production rds field: acv_amount (NUMERIC(18,4))'
- name: acv_amount_base
type: NUMERIC(18,4)
required: true
description: 'Production rds field: acv_amount_base (NUMERIC(18,4))'
- name: new_booking_amount
type: NUMERIC(18,4)
required: true
description: 'Production rds field: new_booking_amount (NUMERIC(18,4))'
- name: new_booking_amount_base
type: NUMERIC(18,4)
required: true
description: 'Production rds field: new_booking_amount_base (NUMERIC(18,4))'
- name: renewal_booking_amount
type: NUMERIC(18,4)
required: true
description: 'Production rds field: renewal_booking_amount (NUMERIC(18,4))'
- name: renewal_booking_amount_base
type: NUMERIC(18,4)
required: true
description: 'Production rds field: renewal_booking_amount_base (NUMERIC(18,4))'
- name: previous_acv_amount
type: NUMERIC(18,4)
required: true
description: 'Production rds field: previous_acv_amount (NUMERIC(18,4))'
- name: previous_acv_amount_base
type: NUMERIC(18,4)
required: true
description: 'Production rds field: previous_acv_amount_base (NUMERIC(18,4))'
- name: total_discount_amount
type: NUMERIC(18,4)
required: true
description: 'Production rds field: total_discount_amount (NUMERIC(18,4))'
- name: total_discount_amount_base
type: NUMERIC(18,4)
required: true
description: 'Production rds field: total_discount_amount_base (NUMERIC(18,4))'
- name: weighted_value
type: NUMERIC(18,4)
required: true
description: 'Production rds field: weighted_value (NUMERIC(18,4))'
- name: close_probability
type: INTEGER
required: true
description: 'Production rds field: close_probability (INTEGER)'
- name: estimated_close_date
type: TIMESTAMPTZ
required: false
description: 'Production rds field: estimated_close_date (TIMESTAMPTZ)'
- name: contract_expiry_date
type: TIMESTAMPTZ
required: false
description: 'Production rds field: contract_expiry_date (TIMESTAMPTZ)'
- name: entitlement_expiry_date
type: TIMESTAMPTZ
required: false
description: 'Production rds field: entitlement_expiry_date (TIMESTAMPTZ)'
- name: budget_status
type: TEXT
required: true
description: 'Production rds field: budget_status (TEXT)'
- name: budget_amount
type: NUMERIC(18,4)
required: true
description: 'Production rds field: budget_amount (NUMERIC(18,4))'
- name: budget_amount_base
type: NUMERIC(18,4)
required: true
description: 'Production rds field: budget_amount_base (NUMERIC(18,4))'
- name: purchase_timeframe
type: TEXT
required: true
description: 'Production rds field: purchase_timeframe (TEXT)'
- name: purchase_process
type: TEXT
required: true
description: 'Production rds field: purchase_process (TEXT)'
- name: next_step_text
type: TEXT
required: true
description: 'Production rds field: next_step_text (TEXT)'
- name: next_step_date
type: TIMESTAMPTZ
required: false
description: 'Production rds field: next_step_date (TIMESTAMPTZ)'
- name: closed_lost_reason
type: TEXT
required: true
description: 'Production rds field: closed_lost_reason (TEXT)'
- name: closed_won_reason
type: TEXT
required: true
description: 'Production rds field: closed_won_reason (TEXT)'
- name: rep_risk_notes
type: TEXT
required: true
description: 'Production rds field: rep_risk_notes (TEXT)'
- name: is_proposal_presented
type: BOOLEAN
required: true
description: 'Production rds field: is_proposal_presented (BOOLEAN)'
- name: is_active_trial
type: BOOLEAN
required: true
description: 'Production rds field: is_active_trial (BOOLEAN)'
- name: meddpicc_metrics_met
type: BOOLEAN
required: true
description: 'Production rds field: meddpicc_metrics_met (BOOLEAN)'
- name: meddpicc_economic_buyer_met
type: BOOLEAN
required: true
description: 'Production rds field: meddpicc_economic_buyer_met (BOOLEAN)'
- name: meddpicc_decision_criteria_met
type: BOOLEAN
required: true
description: 'Production rds field: meddpicc_decision_criteria_met (BOOLEAN)'
- name: meddpicc_decision_process_met
type: BOOLEAN
required: true
description: 'Production rds field: meddpicc_decision_process_met (BOOLEAN)'
- name: meddpicc_paper_process_met
type: BOOLEAN
required: true
description: 'Production rds field: meddpicc_paper_process_met (BOOLEAN)'
- name: meddpicc_identify_pain_met
type: BOOLEAN
required: true
description: 'Production rds field: meddpicc_identify_pain_met (BOOLEAN)'
- name: meddpicc_champion_met
type: BOOLEAN
required: true
description: 'Production rds field: meddpicc_champion_met (BOOLEAN)'
- name: meddpicc_competition_met
type: BOOLEAN
required: true
description: 'Production rds field: meddpicc_competition_met (BOOLEAN)'
- name: meddpicc_rep_completion_score
type: NUMERIC(5,2)
required: false
description: 'Production rds field: meddpicc_rep_completion_score (NUMERIC(5,2))'
- name: ((meddpicc_metrics_met::int
type: + meddpicc_economic_buyer_met::int +
required: false
description: 'Production rds field: ((meddpicc_metrics_met::int (+ meddpicc_economic_buyer_met::int
+)'
- name: meddpicc_decision_criteria_met::int
type: + meddpicc_decision_process_met::int +
required: false
description: 'Production rds field: meddpicc_decision_criteria_met::int (+ meddpicc_decision_process_met::int
+)'
- name: meddpicc_paper_process_met::int
type: + meddpicc_identify_pain_met::int +
required: false
description: 'Production rds field: meddpicc_paper_process_met::int (+ meddpicc_identify_pain_met::int
+)'
- name: meddpicc_champion_met::int
type: + meddpicc_competition_met::int) * 12.5)
required: false
description: 'Production rds field: meddpicc_champion_met::int (+ meddpicc_competition_met::int)
* 12.5))'
- name: )
type: STORED
required: false
description: 'Production rds field: ) (STORED)'
- name: meddpicc_ai_completion_score
type: NUMERIC(5,2)
required: true
description: 'Production rds field: meddpicc_ai_completion_score (NUMERIC(5,2))'
- name: meddpicc_rep_evidence
type: JSONB
required: true
description: 'Production rds field: meddpicc_rep_evidence (JSONB)'
- name: meddpicc_ai_audit
type: JSONB
required: true
description: 'Production rds field: meddpicc_ai_audit (JSONB)'
- name: meddpicc_last_audited_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: meddpicc_last_audited_at (TIMESTAMPTZ)'
- name: executive_sponsor_person_id
type: TEXT
required: true
description: 'Production rds field: executive_sponsor_person_id (TEXT)'
- name: actual_close_date
type: TIMESTAMPTZ
required: false
description: 'Production rds field: actual_close_date (TIMESTAMPTZ)'
- name: actual_revenue
type: NUMERIC(18,4)
required: true
description: 'Production rds field: actual_revenue (NUMERIC(18,4))'
- name: actual_revenue_base
type: NUMERIC(18,4)
required: true
description: 'Production rds field: actual_revenue_base (NUMERIC(18,4))'
- name: is_won
type: BOOLEAN
required: true
description: 'Production rds field: is_won (BOOLEAN)'
- name: is_closed
type: BOOLEAN
required: true
description: 'Production rds field: is_closed (BOOLEAN)'
- name: territory_id
type: TEXT
required: true
description: 'Production rds field: territory_id (TEXT)'
- name: territory_name
type: TEXT
required: true
description: 'Production rds field: territory_name (TEXT)'
- name: campaign_id
type: TEXT
required: true
description: 'Production rds field: campaign_id (TEXT)'
- name: partner_id
type: TEXT
required: true
description: 'Production rds field: partner_id (TEXT)'
- name: engagement_model
type: TEXT
required: true
description: 'Production rds field: engagement_model (TEXT)'
- name: customer_success_manager_id
type: TEXT
required: true
description: 'Production rds field: customer_success_manager_id (TEXT)'
- name: reporting_datetime
type: TIMESTAMPTZ
required: true
description: 'Production rds field: reporting_datetime (TIMESTAMPTZ)'
- name: currency_code
type: TEXT
required: true
description: 'Production rds field: currency_code (TEXT)'
- name: exchange_rate
type: NUMERIC(18,8)
required: true
description: 'Production rds field: exchange_rate (NUMERIC(18,8))'
- name: created_by
type: UUID
required: false
description: 'Production rds field: created_by (UUID)'
- name: created_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: created_at (TIMESTAMPTZ)'
- name: updated_by
type: UUID
required: false
description: 'Production rds field: updated_by (UUID)'
- name: updated_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: updated_at (TIMESTAMPTZ)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **Opportunity** records stored in the relational database.
---
id: OpportunityHistory
name: OpportunityHistory
version: 1.0.0
summary: ClickHouse fact table representing historical snapshots of opportunity records.
owners:
- revenue-intelligence
properties:
- name: opportunity_internal_id
type: UUID
required: true
description: 'Production clickhouse field: opportunity_internal_id (UUID)'
- name: tenant_id
type: UUID
required: true
description: 'Production clickhouse field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: source_system
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: source_system (LowCardinality(String))'
- name: source_opportunity_id
type: String
required: true
description: 'Production clickhouse field: source_opportunity_id (String)'
- name: source_account_id
type: String
required: true
description: 'Production clickhouse field: source_account_id (String)'
- name: source_owner_id
type: String
required: true
description: 'Production clickhouse field: source_owner_id (String)'
- name: originating_lead_id
type: String
required: true
description: 'Production clickhouse field: originating_lead_id (String)'
- name: snapshot_date
type: DateTime
required: true
description: 'Production clickhouse field: snapshot_date (DateTime)'
- name: opportunity_name
type: String
required: true
description: 'Production clickhouse field: opportunity_name (String)'
- name: source_opportunity_type
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: source_opportunity_type (LowCardinality(String))'
- name: standard_opportunity_type
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: standard_opportunity_type (LowCardinality(String))'
- name: source_sales_stage
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: source_sales_stage (LowCardinality(String))'
- name: standard_sales_stage
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: standard_sales_stage (LowCardinality(String))'
- name: source_forecast_category
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: source_forecast_category (LowCardinality(String))'
- name: standard_forecast_category
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: standard_forecast_category (LowCardinality(String))'
- name: budget_status
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: budget_status (LowCardinality(String))'
- name: budget_amount
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: budget_amount (Decimal(18, 4))'
- name: budget_amount_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: budget_amount_base (Decimal(18, 4))'
- name: currency_code
type: String
required: true
description: 'Production clickhouse field: currency_code (String)'
- name: exchange_rate
type: Decimal(18, 8)
required: true
description: 'Production clickhouse field: exchange_rate (Decimal(18, 8))'
- name: purchase_timeframe
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: purchase_timeframe (LowCardinality(String))'
- name: purchase_process
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: purchase_process (LowCardinality(String))'
- name: estimated_close_date
type: ''
required: true
description: 'Production clickhouse field: estimated_close_date ()'
- name: contract_expiry_date
type: ''
required: true
description: 'Production clickhouse field: contract_expiry_date ()'
- name: entitlement_expiry_date
type: ''
required: true
description: 'Production clickhouse field: entitlement_expiry_date ()'
- name: estimated_value
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: estimated_value (Decimal(18, 4))'
- name: estimated_value_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: estimated_value_base (Decimal(18, 4))'
- name: acv_amount
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: acv_amount (Decimal(18, 4))'
- name: acv_amount_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: acv_amount_base (Decimal(18, 4))'
- name: new_booking_amount
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: new_booking_amount (Decimal(18, 4))'
- name: new_booking_amount_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: new_booking_amount_base (Decimal(18,
4))'
- name: renewal_booking_amount
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: renewal_booking_amount (Decimal(18, 4))'
- name: renewal_booking_amount_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: renewal_booking_amount_base (Decimal(18,
4))'
- name: previous_acv_amount
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: previous_acv_amount (Decimal(18, 4))'
- name: previous_acv_amount_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: previous_acv_amount_base (Decimal(18,
4))'
- name: total_discount_amount
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: total_discount_amount (Decimal(18, 4))'
- name: total_discount_amount_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: total_discount_amount_base (Decimal(18,
4))'
- name: weighted_value
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: weighted_value (Decimal(18, 4))'
- name: close_probability
type: Int32
required: true
description: 'Production clickhouse field: close_probability (Int32)'
- name: next_step_text
type: String
required: true
description: 'Production clickhouse field: next_step_text (String)'
- name: next_step_date
type: ''
required: true
description: 'Production clickhouse field: next_step_date ()'
- name: rep_risk_notes
type: String
required: true
description: 'Production clickhouse field: rep_risk_notes (String)'
- name: closed_lost_reason
type: String
required: true
description: 'Production clickhouse field: closed_lost_reason (String)'
- name: closed_won_reason
type: String
required: true
description: 'Production clickhouse field: closed_won_reason (String)'
- name: is_proposal_presented
type: UInt8
required: true
description: 'Production clickhouse field: is_proposal_presented (UInt8)'
- name: is_active_trial
type: UInt8
required: true
description: 'Production clickhouse field: is_active_trial (UInt8)'
- name: executive_sponsor_person_id
type: String
required: true
description: 'Production clickhouse field: executive_sponsor_person_id (String)'
- name: actual_close_date
type: ''
required: true
description: 'Production clickhouse field: actual_close_date ()'
- name: actual_revenue
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: actual_revenue (Decimal(18, 4))'
- name: actual_revenue_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: actual_revenue_base (Decimal(18, 4))'
- name: is_won
type: UInt8
required: true
description: 'Production clickhouse field: is_won (UInt8)'
- name: is_closed
type: UInt8
required: true
description: 'Production clickhouse field: is_closed (UInt8)'
- name: campaign_id
type: String
required: true
description: 'Production clickhouse field: campaign_id (String)'
- name: engagement_model
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: engagement_model (LowCardinality(String))'
- name: customer_success_manager_id
type: String
required: true
description: 'Production clickhouse field: customer_success_manager_id (String)'
- name: partner_id
type: String
required: true
description: 'Production clickhouse field: partner_id (String)'
- name: territory_id
type: String
required: true
description: 'Production clickhouse field: territory_id (String)'
- name: territory_name
type: String
required: true
description: 'Production clickhouse field: territory_name (String)'
- name: meddpicc_metrics_met
type: UInt8
required: true
description: 'Production clickhouse field: meddpicc_metrics_met (UInt8)'
- name: meddpicc_economic_buyer_met
type: UInt8
required: true
description: 'Production clickhouse field: meddpicc_economic_buyer_met (UInt8)'
- name: meddpicc_decision_criteria_met
type: UInt8
required: true
description: 'Production clickhouse field: meddpicc_decision_criteria_met (UInt8)'
- name: meddpicc_decision_process_met
type: UInt8
required: true
description: 'Production clickhouse field: meddpicc_decision_process_met (UInt8)'
- name: meddpicc_paper_process_met
type: UInt8
required: true
description: 'Production clickhouse field: meddpicc_paper_process_met (UInt8)'
- name: meddpicc_identify_pain_met
type: UInt8
required: true
description: 'Production clickhouse field: meddpicc_identify_pain_met (UInt8)'
- name: meddpicc_champion_met
type: UInt8
required: true
description: 'Production clickhouse field: meddpicc_champion_met (UInt8)'
- name: meddpicc_competition_met
type: UInt8
required: true
description: 'Production clickhouse field: meddpicc_competition_met (UInt8)'
- name: meddpicc_rep_completion_score
type: Decimal(5, 2)
required: true
description: 'Production clickhouse field: meddpicc_rep_completion_score (Decimal(5,
2))'
- name: meddpicc_ai_completion_score
type: Decimal(5, 2)
required: true
description: 'Production clickhouse field: meddpicc_ai_completion_score (Decimal(5,
2))'
- name: meddpicc_rep_evidence
type: JSON
required: true
description: 'Production clickhouse field: meddpicc_rep_evidence (JSON)'
- name: meddpicc_ai_audit
type: JSON
required: true
description: 'Production clickhouse field: meddpicc_ai_audit (JSON)'
- name: meddpicc_last_audited_at
type: ''
required: true
description: 'Production clickhouse field: meddpicc_last_audited_at ()'
- name: snapshot_level_1_id
type: String
required: true
description: 'Production clickhouse field: snapshot_level_1_id (String)'
- name: snapshot_level_2_id
type: String
required: true
description: 'Production clickhouse field: snapshot_level_2_id (String)'
- name: snapshot_level_3_id
type: String
required: true
description: 'Production clickhouse field: snapshot_level_3_id (String)'
- name: snapshot_level_4_id
type: String
required: true
description: 'Production clickhouse field: snapshot_level_4_id (String)'
- name: snapshot_level_5_id
type: String
required: true
description: 'Production clickhouse field: snapshot_level_5_id (String)'
- name: snapshot_level_6_id
type: String
required: true
description: 'Production clickhouse field: snapshot_level_6_id (String)'
- name: snapshot_level_7_id
type: String
required: true
description: 'Production clickhouse field: snapshot_level_7_id (String)'
- name: snapshot_level_8_id
type: String
required: true
description: 'Production clickhouse field: snapshot_level_8_id (String)'
- name: snapshot_level_9_id
type: String
required: true
description: 'Production clickhouse field: snapshot_level_9_id (String)'
- name: snapshot_level_10_id
type: String
required: true
description: 'Production clickhouse field: snapshot_level_10_id (String)'
- name: snapshot_user_group_id
type: UUID
required: true
description: 'Production clickhouse field: snapshot_user_group_id (UUID)'
- name: created_by
type: String
required: true
description: 'Production clickhouse field: created_by (String)'
- name: updated_by
type: String
required: true
description: 'Production clickhouse field: updated_by (String)'
- name: created_at
type: ''
required: true
description: 'Production clickhouse field: created_at ()'
- name: updated_at
type: ''
required: true
description: 'Production clickhouse field: updated_at ()'
- name: PROJECTION
type: proj_by_account (
required: true
description: 'Production clickhouse field: PROJECTION (proj_by_account ()'
- name: SELECT
type: '* ORDER BY tenant_id, source_account_id, snapshot_date'
required: true
description: 'Production clickhouse field: SELECT (* ORDER BY tenant_id, source_account_id,
snapshot_date)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **OpportunityHistory** (`fact_opportunity_history`) snapshots stored in the ClickHouse analytical database.
### ClickHouse Engine Configuration
* **Engine**: `MergeTree()`
* **Partition Key**: `toYYYYMM(snapshot_date)`
* **Sorting / Order Key**: `(tenant_id, source_owner_id, source_opportunity_id, snapshot_date)`
* **TTL**: `snapshot_date + INTERVAL 3 YEAR`
### Indexes
The table defines the following secondary indexes for optimized query routing:
* `idx_owner`: `source_owner_id` (bloom_filter, granularity 1)
* `idx_stage`: `standard_sales_stage` (set, granularity 4)
* `idx_close_date`: `estimated_close_date` (minmax, granularity 4)
* `idx_is_closed`: `is_closed` (set, granularity 4)
* `idx_dedup`: `(tenant_id, source_opportunity_id, snapshot_date)` (minmax, granularity 1) - used to enforce and accelerate history snapshot deduplication queries.
### Projections
* `proj_by_account`: Optimization to speed up account-scoped history queries.
```sql
SELECT * ORDER BY tenant_id, source_account_id, snapshot_date
```
---
id: OpportunityProduct
name: OpportunityProduct
version: 1.0.0
summary: ClickHouse fact table representing historical snapshots of opportunity product
line items.
owners:
- revenue-intelligence
properties:
- name: opp_product_internal_id
type: UUID
required: true
description: 'Production clickhouse field: opp_product_internal_id (UUID)'
- name: tenant_id
type: UUID
required: true
description: 'Production clickhouse field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: source_system
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: source_system (LowCardinality(String))'
- name: source_opp_product_id
type: String
required: true
description: 'Production clickhouse field: source_opp_product_id (String)'
- name: source_opportunity_id
type: String
required: true
description: 'Production clickhouse field: source_opportunity_id (String)'
- name: source_product_id
type: String
required: true
description: 'Production clickhouse field: source_product_id (String)'
- name: snapshot_date
type: DateTime
required: true
description: 'Production clickhouse field: snapshot_date (DateTime)'
- name: source_revenue_type
type: String
required: true
description: 'Production clickhouse field: source_revenue_type (String)'
- name: standard_revenue_type
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: standard_revenue_type (LowCardinality(String))'
- name: product_category
type: String
required: true
description: 'Production clickhouse field: product_category (String)'
- name: product_subcategory
type: String
required: true
description: 'Production clickhouse field: product_subcategory (String)'
- name: term_length
type: String
required: true
description: 'Production clickhouse field: term_length (String)'
- name: quantity
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: quantity (Decimal(18, 4))'
- name: unit_price
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: unit_price (Decimal(18, 4))'
- name: unit_price_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: unit_price_base (Decimal(18, 4))'
- name: new_revenue_amount
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: new_revenue_amount (Decimal(18, 4))'
- name: new_revenue_amount_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: new_revenue_amount_base (Decimal(18,
4))'
- name: renewal_revenue_amount
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: renewal_revenue_amount (Decimal(18, 4))'
- name: renewal_revenue_amount_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: renewal_revenue_amount_base (Decimal(18,
4))'
- name: upside_amount
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: upside_amount (Decimal(18, 4))'
- name: upside_amount_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: upside_amount_base (Decimal(18, 4))'
- name: estimated_bcv
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: estimated_bcv (Decimal(18, 4))'
- name: validated_bcv
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: validated_bcv (Decimal(18, 4))'
- name: family_total_amount
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: family_total_amount (Decimal(18, 4))'
- name: family_total_amount_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: family_total_amount_base (Decimal(18,
4))'
- name: new_discount_amount
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: new_discount_amount (Decimal(18, 4))'
- name: new_discount_amount_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: new_discount_amount_base (Decimal(18,
4))'
- name: new_discount_percentage
type: Decimal(18, 2)
required: true
description: 'Production clickhouse field: new_discount_percentage (Decimal(18,
2))'
- name: renewal_discount_amount
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: renewal_discount_amount (Decimal(18,
4))'
- name: renewal_discount_amount_base
type: Decimal(18, 4)
required: true
description: 'Production clickhouse field: renewal_discount_amount_base (Decimal(18,
4))'
- name: renewal_discount_percentage
type: Decimal(18, 2)
required: true
description: 'Production clickhouse field: renewal_discount_percentage (Decimal(18,
2))'
- name: currency_code
type: String
required: true
description: 'Production clickhouse field: currency_code (String)'
- name: exchange_rate
type: Decimal(18, 8)
required: true
description: 'Production clickhouse field: exchange_rate (Decimal(18, 8))'
- name: is_active
type: UInt8
required: true
description: 'Production clickhouse field: is_active (UInt8)'
- name: created_by
type: String
required: true
description: 'Production clickhouse field: created_by (String)'
- name: updated_by
type: String
required: true
description: 'Production clickhouse field: updated_by (String)'
- name: created_at
type: ''
required: true
description: 'Production clickhouse field: created_at ()'
- name: updated_at
type: ''
required: true
description: 'Production clickhouse field: updated_at ()'
- name: PROJECTION
type: proj_latest_snapshot (
required: true
description: 'Production clickhouse field: PROJECTION (proj_latest_snapshot ()'
- name: SELECT
type: tenant_id, source_opportunity_id, max(snapshot_date) as max_date
required: true
description: 'Production clickhouse field: SELECT (tenant_id, source_opportunity_id,
max(snapshot_date) as max_date)'
- name: GROUP
type: BY tenant_id, source_opportunity_id
required: true
description: 'Production clickhouse field: GROUP (BY tenant_id, source_opportunity_id)'
- name: PROJECTION
type: proj_by_product (
required: true
description: 'Production clickhouse field: PROJECTION (proj_by_product ()'
- name: SELECT
type: tenant_id, source_product_id, snapshot_date, source_opportunity_id
required: true
description: 'Production clickhouse field: SELECT (tenant_id, source_product_id,
snapshot_date, source_opportunity_id)'
- name: ORDER
type: BY tenant_id, source_product_id, snapshot_date
required: true
description: 'Production clickhouse field: ORDER (BY tenant_id, source_product_id,
snapshot_date)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **OpportunityProduct** (`fact_opportunity_product`) snapshots stored in the ClickHouse analytical database, supporting product-level revenue and pipeline analysis.
### ClickHouse Engine Configuration
* **Engine**: `MergeTree()`
* **Partition Key**: `toYYYYMM(snapshot_date)`
* **Sorting / Order Key**: `(tenant_id, source_opportunity_id, source_product_id, snapshot_date)`
* **TTL**: `snapshot_date + INTERVAL 3 YEAR`
### Projections
The table defines two projections to speed up snapshot-based queries:
* `proj_latest_snapshot`: Optimized to retrieve the maximum snapshot date per opportunity.
```sql
SELECT tenant_id, source_opportunity_id, max(snapshot_date) as max_date
GROUP BY tenant_id, source_opportunity_id
```
* `proj_by_product`: Optimized for product family analysis and trend queries.
```sql
SELECT tenant_id, source_product_id, snapshot_date, source_opportunity_id
ORDER BY tenant_id, source_product_id, snapshot_date
```
---
id: OpportunityStageHistory
name: OpportunityStageHistory
version: 1.0.0
summary: ClickHouse fact table representing history of sales stage transitions.
owners:
- revenue-intelligence
properties:
- name: stage_history_internal_id
type: UUID
required: true
description: 'Production clickhouse field: stage_history_internal_id (UUID)'
- name: tenant_id
type: UUID
required: true
description: 'Production clickhouse field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: source_system
type: LowCardinality(String)
required: true
description: 'Production clickhouse field: source_system (LowCardinality(String))'
- name: source_opportunity_id
type: String
required: true
description: 'Production clickhouse field: source_opportunity_id (String)'
- name: stage_name
type: String
required: true
description: 'Production clickhouse field: stage_name (String)'
- name: stage_entered_on
type: ''
required: true
description: 'Production clickhouse field: stage_entered_on ()'
- name: stage_exited_on
type: ''
required: true
description: 'Production clickhouse field: stage_exited_on ()'
- name: duration_in_stage_days
type: Int32
required: true
description: 'Production clickhouse field: duration_in_stage_days (Int32)'
- name: is_stalled
type: UInt8
required: true
description: 'Production clickhouse field: is_stalled (UInt8)'
- name: snapshot_date
type: DateTime
required: true
description: 'Production clickhouse field: snapshot_date (DateTime)'
- name: created_by
type: String
required: true
description: 'Production clickhouse field: created_by (String)'
- name: updated_by
type: String
required: true
description: 'Production clickhouse field: updated_by (String)'
- name: created_at
type: ''
required: true
description: 'Production clickhouse field: created_at ()'
- name: updated_at
type: ''
required: true
description: 'Production clickhouse field: updated_at ()'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **OpportunityStageHistory** (`fact_opportunity_stage_history`) records stored in the ClickHouse analytical database. This table tracks the precise chronological transitions between sales stages.
### ClickHouse Engine Configuration
* **Engine**: `MergeTree()`
* **Partition Key**: `toYYYYMM(snapshot_date)`
* **Sorting / Order Key**: `(tenant_id, source_opportunity_id, snapshot_date)`
* **TTL**: `snapshot_date + INTERVAL 3 YEAR`
---
id: Person
name: Person
version: 1.0.0
summary: Database model representing Person records.
owners:
- revenue-intelligence
properties:
- name: person_internal_id
type: UUID
required: true
description: 'Primary key. UUID v7, generated by the database.'
- name: source_system
type: TEXT
required: true
description: 'Production rds field: source_system (TEXT)'
- name: tenant_id
type: UUID
required: true
description: 'Production rds field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: source_person_id
type: TEXT
required: true
description: 'Production rds field: source_person_id (TEXT)'
- name: manager_source_id
type: TEXT
required: true
description: 'This person''s manager, matched against another person''s source_person_id. This is the edge that forms the org hierarchy.'
references: Person
referencesIdentifier: source_person_id
relationType: hasOne
- name: source_account_id
type: TEXT
required: true
description: 'Production rds field: source_account_id (TEXT)'
- name: person_type
type: TEXT
required: true
description: 'Production rds field: person_type (TEXT)'
- name: is_active
type: BOOLEAN
required: true
description: 'Production rds field: is_active (BOOLEAN)'
- name: first_name
type: TEXT
required: true
description: 'Production rds field: first_name (TEXT)'
- name: last_name
type: TEXT
required: true
description: 'Production rds field: last_name (TEXT)'
- name: email_address
type: TEXT
required: true
description: 'Production rds field: email_address (TEXT)'
- name: primary_email_address
type: TEXT
required: true
description: 'Production rds field: primary_email_address (TEXT)'
- name: secondary_email_address
type: TEXT
required: true
description: 'Production rds field: secondary_email_address (TEXT)'
- name: tertiary_email_address
type: TEXT
required: true
description: 'Production rds field: tertiary_email_address (TEXT)'
- name: internal_email_address
type: TEXT[]
required: true
description: 'Production rds field: internal_email_address (TEXT[])'
- name: title
type: TEXT
required: true
description: 'Production rds field: title (TEXT)'
- name: cal_type
type: TEXT
required: true
description: 'Production rds field: cal_type (TEXT)'
- name: job_title
type: TEXT
required: true
description: 'Production rds field: job_title (TEXT)'
- name: department
type: TEXT
required: true
description: 'Production rds field: department (TEXT)'
- name: standard_role
type: TEXT
required: true
description: 'Production rds field: standard_role (TEXT)'
- name: source_role
type: TEXT
required: true
description: 'Production rds field: source_role (TEXT)'
- name: do_not_email
type: BOOLEAN
required: true
description: 'Production rds field: do_not_email (BOOLEAN)'
- name: do_not_phone
type: BOOLEAN
required: true
description: 'Production rds field: do_not_phone (BOOLEAN)'
- name: region
type: TEXT[]
required: true
description: 'Production rds field: region (TEXT[])'
- name: hierarchy_node_id
type: TEXT
required: true
description: 'Production rds field: hierarchy_node_id (TEXT)'
- name: player_coach_flag
type: BOOLEAN
required: true
description: 'Production rds field: player_coach_flag (BOOLEAN)'
- name: assigned_territory_id
type: TEXT
required: true
description: 'Production rds field: assigned_territory_id (TEXT)'
- name: provisioning_source
type: TEXT
required: true
description: '''CRM'' | ''MANUAL'''
- name: workspace_id
type: UUID
required: false
description: 'Production rds field: workspace_id (UUID)'
- name: hierarchy_path
type: TEXT
required: true
description: 'Production rds field: hierarchy_path (TEXT)'
- name: no_of_reportees
type: INT
required: true
description: 'Production rds field: no_of_reportees (INT)'
- name: level_1_id
type: TEXT
required: true
description: 'Production rds field: level_1_id (TEXT)'
- name: level_2_id
type: TEXT
required: true
description: 'Production rds field: level_2_id (TEXT)'
- name: level_3_id
type: TEXT
required: true
description: 'Production rds field: level_3_id (TEXT)'
- name: level_4_id
type: TEXT
required: true
description: 'Production rds field: level_4_id (TEXT)'
- name: level_5_id
type: TEXT
required: true
description: 'Production rds field: level_5_id (TEXT)'
- name: level_6_id
type: TEXT
required: true
description: 'Production rds field: level_6_id (TEXT)'
- name: level_7_id
type: TEXT
required: true
description: 'Production rds field: level_7_id (TEXT)'
- name: level_8_id
type: TEXT
required: true
description: 'Production rds field: level_8_id (TEXT)'
- name: level_9_id
type: TEXT
required: true
description: 'Production rds field: level_9_id (TEXT)'
- name: level_10_id
type: TEXT
required: true
description: 'Production rds field: level_10_id (TEXT)'
- name: user_group_id
type: UUID
required: false
description: 'Production rds field: user_group_id (UUID)'
- name: reporting_datetime
type: TIMESTAMPTZ
required: true
description: 'Production rds field: reporting_datetime (TIMESTAMPTZ)'
- name: created_by
type: UUID
required: false
description: 'Production rds field: created_by (UUID)'
- name: created_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: created_at (TIMESTAMPTZ)'
- name: updated_by
type: UUID
required: false
description: 'Production rds field: updated_by (UUID)'
- name: updated_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: updated_at (TIMESTAMPTZ)'
- name: timezone
type: TEXT
required: false
description: 'Production rds field: timezone (TEXT)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
**Person** is the most widely-shared table in RIO. Almost every service reads it: Commit for
hierarchy rollups, Opportunity for visibility rules, Audit for permission checks, Notification for
reminder recipients.
Source of truth: `rio-database-schemas/rio/rds/02_core.sql:73`.
### How the hierarchy is stored
Rather than walking a manager chain at query time, the hierarchy is **flattened onto every row**:
- `manager_source_id` — the direct manager, matched against another person's `source_person_id`.
- `hierarchy_path` — the full ancestry as a single string.
- `level_1_id` … `level_10_id` — each ancestor at each depth.
That is why a service can answer "everyone under this manager" with one indexed comparison instead of
a recursive query. It is also why a manager change is expensive: every descendant's levels must be
recomputed. That recomputation is what
Hierarchy Updated triggers.
### Two things worth knowing
**`provisioning_source` protects manual users.** A person is either `CRM` (owned by the CRM sync and
overwritten on every run) or `MANUAL` (created in RIO and protected — the sync skips them).
**This table is excluded from row-level security.** Every other tenant-scoped table has an RLS policy
applied by `rio/rds/20_security_rls.sql`, but `person` is deliberately exempt so cross-tenant
authentication lookups can resolve a user before their tenant is known.
Every field below is `NOT NULL` in the DDL, most with a default of `''`, `0` or `FALSE` — so reading
a value does not tell you whether it was ever actually populated.
---
id: Quota
name: Quota
version: 1.0.0
summary: Database model representing Quota records.
owners:
- revenue-intelligence
properties:
- name: quota_internal_id
type: UUID
required: true
description: 'Production rds field: quota_internal_id (UUID)'
- name: tenant_id
type: UUID
required: true
description: 'Production rds field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: source_system
type: TEXT
required: true
description: 'Production rds field: source_system (TEXT)'
- name: quota_subject_type
type: TEXT
required: true
description: 'Production rds field: quota_subject_type (TEXT)'
- name: quota_subject_id
type: UUID
required: true
description: 'Production rds field: quota_subject_id (UUID)'
- name: quota_scope
type: TEXT
required: true
description: 'Production rds field: quota_scope (TEXT)'
- name: hierarchy_type
type: TEXT
required: true
description: 'Production rds field: hierarchy_type (TEXT)'
- name: revenue_type
type: TEXT
required: true
description: 'Production rds field: revenue_type (TEXT)'
- name: period_type
type: TEXT
required: true
description: 'Production rds field: period_type (TEXT)'
- name: fiscal_year
type: INT
required: true
description: 'Production rds field: fiscal_year (INT)'
- name: fiscal_period
type: TEXT
required: true
description: 'Production rds field: fiscal_period (TEXT)'
- name: period_start_date
type: DATE
required: true
description: 'Production rds field: period_start_date (DATE)'
- name: period_end_date
type: DATE
required: true
description: 'Production rds field: period_end_date (DATE)'
- name: quota_amount
type: NUMERIC(18,4)
required: true
description: 'Production rds field: quota_amount (NUMERIC(18,4))'
- name: currency_code
type: TEXT
required: true
description: 'Production rds field: currency_code (TEXT)'
- name: quota_status
type: TEXT
required: true
description: 'Production rds field: quota_status (TEXT)'
- name: is_locked
type: BOOLEAN
required: true
description: 'Production rds field: is_locked (BOOLEAN)'
- name: locked_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: locked_at (TIMESTAMPTZ)'
- name: editable_until
type: TIMESTAMPTZ
required: true
description: 'Production rds field: editable_until (TIMESTAMPTZ)'
- name: notes
type: TEXT
required: true
description: 'Production rds field: notes (TEXT)'
- name: created_by
type: UUID
required: true
description: 'Production rds field: created_by (UUID)'
- name: updated_by
type: UUID
required: true
description: 'Production rds field: updated_by (UUID)'
- name: created_at
type: TIMESTAMPTZ
required: true
description: 'Production rds field: created_at (TIMESTAMPTZ)'
- name: updated_at
type: TIMESTAMPTZ
required: true
description: 'Production rds field: updated_at (TIMESTAMPTZ)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **Quota** records stored in the relational database.
---
id: QuotaAudit
name: QuotaAudit
version: 1.0.0
summary: Database model representing QuotaAudit records.
owners:
- revenue-intelligence
properties:
- name: audit_id
type: UUID
required: true
description: 'Production rds field: audit_id (UUID)'
- name: tenant_id
type: UUID
required: true
description: 'Production rds field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: quota_internal_id
type: UUID
required: true
description: 'Production rds field: quota_internal_id (UUID)'
- name: action_type
type: TEXT
required: true
description: 'Production rds field: action_type (TEXT)'
- name: old_value
type: JSONB
required: false
description: 'Production rds field: old_value (JSONB)'
- name: new_value
type: JSONB
required: false
description: 'Production rds field: new_value (JSONB)'
- name: changed_by
type: UUID
required: true
description: 'Production rds field: changed_by (UUID)'
- name: changed_at
type: TIMESTAMPTZ
required: true
description: 'Production rds field: changed_at (TIMESTAMPTZ)'
- name: change_reason
type: TEXT
required: true
description: 'Production rds field: change_reason (TEXT)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **QuotaAudit** records stored in the relational database.
---
id: Tag
name: Tag
version: 1.0.0
summary: Tenant-scoped labels carrying a permission array. Used to grant capabilities without creating a new role.
owners:
- revenue-intelligence
properties:
- name: tag_id
type: UUID
required: true
description: 'Primary key. UUID v7, generated by the database.'
- name: tenant_id
type: UUID
required: true
description: 'Owning tenant.'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: tag_name
type: TEXT
required: true
description: 'Label text. Unique per tenant, but only among active tags - the constraint is a partial unique index with WHERE is_active.'
- name: permissions
type: TEXT[]
required: true
description: 'Permission strings this tag grants. Indexed with GIN so a permission can be searched across tags.'
- name: is_active
type: BOOLEAN
required: true
description: 'Soft-delete flag. Deactivating a tag frees its name for reuse.'
- name: created_by
type: UUID
required: false
description: 'Person who created the tag.'
- name: created_at
type: TIMESTAMPTZ
required: false
description: 'Creation timestamp.'
- name: updated_by
type: UUID
required: false
description: 'Person who last changed the tag.'
- name: updated_at
type: TIMESTAMPTZ
required: false
description: 'Last change timestamp.'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
A **Tag** is a lightweight permission carrier. Where
TenantRole defines a person's structural
position and its `can_*` booleans, a Tag grants extra capabilities without needing a whole new role.
Two details worth knowing:
- **Uniqueness is conditional.** The unique constraint on `(tenant_id, tag_name)` is a *partial*
index with `WHERE is_active`. Deactivate a tag and its name becomes available again — so tag names
are not unique across history.
- **`permissions` is a GIN-indexed array**, so you can efficiently ask "which tags grant this
permission" rather than only "what does this tag grant".
Source of truth: `rio-database-schemas/rio/rds/05_platform.sql:11`. Managed through the Identity
Service's `/tenants/{tenant_id}/tags` endpoints. Its history is replicated to ClickHouse as
`dim_tag_history` by ClickPipe.
---
id: Tenant
name: Tenant
version: 1.0.0
summary: Database model representing Tenant records.
owners:
- revenue-intelligence
properties:
- name: tenant_id
type: UUID
required: true
description: 'Production rds field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: tenant_name
type: TEXT
required: true
description: 'Production rds field: tenant_name (TEXT)'
- name: status
type: TEXT
required: true
description: 'Production rds field: status (TEXT)'
- name: subscription_tier
type: TEXT
required: true
description: 'Production rds field: subscription_tier (TEXT)'
- name: subscription_end_date
type: TIMESTAMPTZ
required: false
description: 'Production rds field: subscription_end_date (TIMESTAMPTZ)'
- name: is_test
type: BOOLEAN
required: true
description: 'Production rds field: is_test (BOOLEAN)'
- name: created_by
type: UUID
required: false
description: 'Production rds field: created_by (UUID)'
- name: created_at
type: TIMESTAMPTZ
required: true
description: 'Production rds field: created_at (TIMESTAMPTZ)'
- name: updated_by
type: UUID
required: false
description: 'Production rds field: updated_by (UUID)'
- name: updated_at
type: TIMESTAMPTZ
required: true
description: 'Production rds field: updated_at (TIMESTAMPTZ)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **Tenant** records stored in the relational database.
---
id: TenantRole
name: TenantRole
version: 1.0.0
summary: Database model representing TenantRole records.
owners:
- revenue-intelligence
properties:
- name: role_internal_id
type: UUID
required: true
description: 'Production rds field: role_internal_id (UUID)'
- name: tenant_id
type: UUID
required: true
description: 'Production rds field: tenant_id (UUID)'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: role_code
type: TEXT
required: true
description: 'Production rds field: role_code (TEXT)'
- name: role_name
type: TEXT
required: true
description: 'Production rds field: role_name (TEXT)'
- name: role_type
type: TEXT
required: true
description: leaf | manager | executive | overlay
- name: workspace_id
type: UUID
required: false
description: 'Production rds field: workspace_id (UUID)'
- name: hierarchy_type
type: TEXT
required: true
description: 'Production rds field: hierarchy_type (TEXT)'
- name: can_view_subtree
type: BOOLEAN
required: true
description: 'Production rds field: can_view_subtree (BOOLEAN)'
- name: can_adjust_forecast
type: BOOLEAN
required: true
description: 'Production rds field: can_adjust_forecast (BOOLEAN)'
- name: can_submit_forecast
type: BOOLEAN
required: true
description: 'Production rds field: can_submit_forecast (BOOLEAN)'
- name: can_view_attributed
type: BOOLEAN
required: true
description: 'Production rds field: can_view_attributed (BOOLEAN)'
- name: can_view_team_data
type: BOOLEAN
required: true
description: 'Production rds field: can_view_team_data (BOOLEAN)'
- name: can_view_region_data
type: BOOLEAN
required: true
description: 'Production rds field: can_view_region_data (BOOLEAN)'
- name: can_view_audit
type: BOOLEAN
required: true
description: 'Production rds field: can_view_audit (BOOLEAN)'
- name: provisioning_source
type: TEXT
required: true
description: '''CRM'' | ''MANUAL'''
- name: can_manage_team
type: BOOLEAN
required: true
description: 'Production rds field: can_manage_team (BOOLEAN)'
- name: can_manage_quota
type: BOOLEAN
required: true
description: 'Production rds field: can_manage_quota (BOOLEAN)'
- name: is_revenue_owner
type: BOOLEAN
required: true
description: 'Production rds field: is_revenue_owner (BOOLEAN)'
- name: can_modify
type: BOOLEAN
required: true
description: 'Production rds field: can_modify (BOOLEAN)'
- name: is_active
type: BOOLEAN
required: true
description: 'Production rds field: is_active (BOOLEAN)'
- name: created_by
type: UUID
required: false
description: 'Production rds field: created_by (UUID)'
- name: created_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: created_at (TIMESTAMPTZ)'
- name: updated_by
type: UUID
required: false
description: 'Production rds field: updated_by (UUID)'
- name: updated_at
type: TIMESTAMPTZ
required: false
description: 'Production rds field: updated_at (TIMESTAMPTZ)'
- name: source_system
type: TEXT
required: true
description: 'Production rds field: source_system (TEXT)'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
Defines the structure of **TenantRole** records stored in the relational database.
---
id: UserGroup
name: UserGroup
version: 1.0.0
summary: Rule-driven groupings of people within a tenant. Membership is expressed as JSONB rules rather than a fixed member list.
owners:
- revenue-intelligence
properties:
- name: user_group_id
type: UUID
required: true
description: 'Primary key. UUID v7, generated by the database.'
- name: tenant_id
type: UUID
required: true
description: 'Owning tenant.'
references: Tenant
referencesIdentifier: tenant_id
relationType: hasOne
- name: group_name
type: TEXT
required: true
description: 'Display name of the group.'
- name: rules
type: JSONB
required: true
description: 'Membership criteria, stored as JSONB and indexed with GIN. Membership is evaluated from these rules rather than stored as an explicit member list.'
- name: is_active
type: BOOLEAN
required: true
description: 'Soft-delete flag. Groups are deactivated, not removed.'
- name: created_by
type: UUID
required: false
description: 'Person who created the group.'
- name: created_at
type: TIMESTAMPTZ
required: false
description: 'Creation timestamp.'
- name: updated_by
type: UUID
required: false
description: 'Person who last changed the group.'
- name: updated_at
type: TIMESTAMPTZ
required: false
description: 'Last change timestamp.'
---
import Footer from '@catalog/components/footer.astro';
## Schema Properties
## Overview
A **UserGroup** is a named set of people inside a tenant — used for things like assigning a
notification audience or scoping a permission to a cohort.
The notable design choice is that membership is **not** a join table. The `rules` column holds JSONB
criteria (indexed with a GIN index), and membership is derived by evaluating those rules. Adding
someone who matches the rules adds them to the group automatically; there is no row to insert.
Source of truth: `rio-database-schemas/rio/rds/02_core.sql:160`. Managed through the Identity
Service's `/tenants/{tenant_id}/user-groups` endpoints, and surfaced in the admin console of
RIO Web App.
Its history is replicated to ClickHouse as `dim_user_group_history` by ClickPipe.
---
id: aws-default-bus
name: AWS Default Event Bus
version: 1.0.0
summary: The account's built-in EventBridge bus, used only for AWS-generated service events (S3, RDS, Bedrock, ECS).
address: default
protocols:
- eventbridge
owners:
- revenue-intelligence
---
import Footer from '@catalog/components/footer.astro';
## Overview
Some RIO rules listen on the account's built-in `default` bus rather than on
the RIO Events Bus. This is because AWS
services always publish their own events to `default` — they cannot be redirected to a custom bus.
These are **AWS-managed events**, so the RIO naming rules in `.claude/rules/eventbridge.md` do not
apply to them; they keep AWS's own structure.
## Rules on this bus
| `source` | `detail-type` | Consumer | Declared in |
|---|---|---|---|
| `aws.s3` | `Object Created` | CRM data-sync state machine, for product-master file uploads | `rio-ingestion-service/infrastructure/modules/ecs/crm-data-sync.yaml:368-390` |
| `aws.rds` | `RDS DB Instance Event` | Lambda that re-registers the RDS IP in a target group | `rio-infra/infrastructure/template.yaml:401-419` |
| `aws.bedrock` | `Batch Inference Job State Change` | Batch assembler, for LLM distillation jobs | `rio-deal-desk/infrastructure/distillation/template.yaml:766-782` |
| `aws.ecs` | _(all ECS detail-types)_ | CloudWatch Logs, 7-day retention | `rio-infra/infrastructure/template.yaml:361-372` |
---
id: rio-events
name: RIO Events Bus
version: 1.0.0
summary: The single custom Amazon EventBridge bus that carries every RIO domain event. One bus per environment.
address: '{env}-rio-events'
protocols:
- eventbridge
parameters:
env:
enum:
- dev
- qa
- prod
description: Deployment environment. The bus is created once per environment.
owners:
- revenue-intelligence
---
import Footer from '@catalog/components/footer.astro';
## Overview
RIO uses **one** custom EventBridge bus per environment — `dev-rio-events`, `qa-rio-events`,
`prod-rio-events`. It is created a single time in `rio-infra`
(`infrastructure/template.yaml:328-331`, logical id `RioEventBus`) and exported as the stack output
`RioEventBusName` (`template.yaml:508`).
Every other service stack receives the bus name as an `EventBusName` CloudFormation parameter rather
than creating its own bus. No repository in the platform declares a second `AWS::Events::EventBus`.
## How consumers subscribe
Because there is one bus, subscribers filter on the event's `source` and `detail-type` fields rather
than on separate streams. Each event page in this catalog records its exact `source` and `detail-type`
so you can write a matching rule.
## Sources published to this bus
| `source` | Publishing service | Repository |
|---|---|---|
| `rio.core` | Identity & Hierarchy Service | `rio-identity-service` |
| `rio.api.hierarchy_change` | Identity & Hierarchy Service (hierarchy fan-out) | `rio-identity-service` |
| `rio.commit` | Commit (Revenue Planning) Service | `rio-commit-service` |
| `rio.platform` | Data Ingestion Service (audit family) | `rio-ingestion-service` |
| `rio.glue.crm_sync` | Data Ingestion Service (hierarchy) | `rio-ingestion-service` |
| `rio.activity` | Activity Signal Service | `rio-deal-desk` |
| `rio.core.activity` | Activity Signal Service (email pipeline) | `rio-deal-desk` |
| `rio.notification.api` | Notification Service (FastAPI) | `rio-platform-notification-service` |
| `rio.forecast-notification` | Notification Service (forecast Lambda) | `rio-platform-notification-service` |
| `rio.platform.notification` | Notification Service (dispatch Lambda) | `rio-platform-notification-service` |
## Naming rules
Per `.claude/rules/eventbridge.md`, custom events on this bus must use a lowercase dotted `source`
and a **space-separated Title Case** `detail-type` (for example `User Created`, not `UserCreated`).
Values nested inside `detail` must be lowercase so EventBridge's exact-match filtering works without
`equals-ignore-case`.
---
id: AuditCapture
name: Audit Capture & Archival
version: 1.0.0
summary: "Where every rio.* event on the platform ends up. Two wildcard EventBridge rules fan each event into a DynamoDB hot store (90-day TTL, four access patterns) and an immutable S3 archive — and quietly drop anything that does not fit the shared event vocabulary."
owners:
- revenue-intelligence
steps:
- id: 1
custom:
title: "Any rio-sourced service publishes"
icon: "MegaphoneIcon"
type: "Event publisher"
color: "blue"
summary: "There is no registration step. If the source string starts with 'rio', the event is audited."
properties:
Bus: "${env}-rio-events"
Known sources: "rio.platform, rio.core, rio.api.hierarchy_change, rio.glue.crm_sync, rio.activity, rio.core.activity, rio.notification.api, rio.forecast-notification, rio.platform.notification"
Required shape: "the DomainEvent envelope in detail"
title: "An event is published"
summary: "Ingestion, identity, commit, activity and notification all publish onto the same bus. None of them know this flow exists."
next_step: 2
- id: 2
custom:
title: "Two wildcard rules"
icon: "FunnelIcon"
type: "AWS EventBridge"
color: "purple"
summary: "Neither rule filters on detail-type. A new event on a new service is audited the day it ships, with no change here."
properties:
Pattern: "source: [{ prefix: 'rio' }]"
Rule 1: "${DeployPrefix}-audit-consumer-rule (modules/lambda/template.yaml:83-95)"
Rule 2: "${DeployPrefix}-archive-consumer-rule (:144-156)"
Coupling: "none — publishers are unaware they are audited"
title: "Fan out on a source prefix"
summary: "Every matching event is delivered twice, to two independent Lambdas that never talk to each other."
next_steps:
- id: 3
label: "hot path"
- id: 4
label: "archive path"
- id: 3
custom:
title: "audit-consumer"
icon: "SparklesIcon"
type: "AWS Lambda"
color: "green"
summary: "Validates against DomainEvent, then enriches — this is the only place raw events become queryable records."
properties:
Validates: "DomainEvent (Pydantic) — 12 required fields, 5 enum-constrained"
Normalises: "domain, subdomain, entity_type, action, status, actor_type lowercased before validation"
Derives: "changed_fields (before vs after diff), severity, summary"
Redacts: "password, secret, token, access_token, refresh_token, api_key, ssn, credit_card, cvv, pin"
DLQ: "${DeployPrefix}-audit-consumer-dlq, 14-day retention"
title: "Validate, enrich, redact"
summary: "Severity is derived, not supplied: failed → error, rejected or deleted or blocked → warn, everything else → info."
next_steps:
- id: 5
label: "valid"
- id: 8
label: "validation error"
- id: 4
custom:
title: "archive-consumer"
icon: "ArchiveBoxIcon"
type: "AWS Lambda"
color: "green"
summary: "Validates the same envelope, redacts the same fields, and writes one JSON object per event."
properties:
Key: "tenant/{tenant_id}/domain/{domain}/{YYYY}/{MM}/{DD}/{event_id}.json"
Partitioning: "derived from occurred_at, not from ingestion time"
Body: "pretty-printed JSON, nulls stripped, archived_at stamped"
DLQ: "${DeployPrefix}-archive-consumer-dlq, 14-day retention"
title: "Shape the archive record"
summary: "No enrichment here — no severity, no summary, no changed_fields. The archive is the envelope, not the audit record."
next_steps:
- id: 6
label: "valid"
- id: 8
label: "validation error"
- id: 5
custom:
title: "Conditional put"
icon: "CircleStackIcon"
type: "Amazon DynamoDB"
color: "gray"
summary: "Duplicates are absorbed, not failed — a redelivered event is logged as a duplicate and returns 200."
properties:
PK: "TENANT#{tenant}#DOMAIN#{domain}#DAY#{YYYYMMDD}"
SK: "TS#{occurred_at}#EVT#{audit_id}"
Condition: "attribute_not_exists(PK) AND attribute_not_exists(SK)"
TTL: "ttl_epoch = occurred_at + AUDIT_TTL_DAYS (default 90)"
title: "Write the hot record"
summary: "Partitioned by tenant + domain + day so a single day's audit trail for one domain is one query."
next_step: 7
- id: 6
custom:
title: "Immutable archive"
icon: "LockClosedIcon"
type: "Amazon S3"
color: "gray"
summary: "No TTL and no expiry. This copy outlives the DynamoDB record by design."
properties:
Bucket: "S3_ARCHIVE_BUCKET (${env}-rio-audit-archive)"
Overwrite: "keyed on event_id — a redelivery rewrites identical content"
Retention: "indefinite"
title: "Archive the event"
summary: "The long-term record, queryable only by object key — there is no API in front of it."
- id: 7
service:
id: "AuditService"
version: "1.0.0"
title: "Query the trail"
summary: "GET /audits over three GSIs — by entity, by actor, or by correlation id — plus the base table by tenant + domain + day."
- id: 8
custom:
title: "Rejected"
icon: "XCircleIcon"
type: "Dead-letter queue"
color: "red"
summary: "A malformed event does not appear anywhere in the audit trail. It is not partially recorded — it is absent."
properties:
Cause: "missing required field, or a value outside the allowed enums"
Path: "handler re-raises → Lambda async retry → SQS DLQ"
Retention: "1,209,600 s (14 days), then gone"
Alerting: "none wired — nothing consumes the DLQs"
title: "Never audited"
summary: "Both consumers validate independently, so a bad event fails both and disappears from both stores."
---
# Audit Capture & Archival
Every other flow in this catalog ends with events on the `${env}-rio-events` bus. **This is what
happens to them.**
The Audit Service is a pure sink — there
is not a single `put_events` call in the repository. It only receives.
> **Tip:** hover a node in the diagram for its full text, or use **Start (walk through business
> flow)** to step through both branches.
## Subscribe to everything, name nothing
Both rules use the same pattern:
```json
{ "source": [{ "prefix": "rio" }] }
```
There is **no `detail-type` filter**. Any event whose source starts with `rio` is captured, with no
change to this service and no awareness on the publisher's side. A new event on a new service is
audited the day it ships.
This is why the service page's `receives` list is empty rather than sixty items long — EventCatalog's
`receives` field can only name specific messages, and the real contract is a prefix.
## Two consumers, deliberately independent
| | `audit-consumer` | `archive-consumer` |
|---|---|---|
| Destination | DynamoDB | S3 |
| Purpose | queryable, recent | immutable, permanent |
| Retention | `AUDIT_TTL_DAYS`, default **90** | **indefinite** |
| Enrichment | severity, summary, `changed_fields` | none |
| Idempotency | conditional put on `PK`/`SK` | key is `event_id`, so a rewrite is identical |
| Own DLQ | yes | yes |
They share only the `DomainEvent` model and the redaction helper. Neither invokes the other, so
DynamoDB throttling cannot stop the archive, and an S3 failure cannot stop the audit trail.
## The shared event vocabulary
Because this service consumes everything, its `DomainEvent` model **is** the platform's event
contract. Twelve fields are required; five are enum-constrained:
| Field | Constraint |
|---|---|
| `domain` | `core`, `commit`, `learn`, `act`, `assess`, `enrich`, `notification`, `platform`, `opportunity` |
| `subdomain` | `identity`, `commit`, `quota`, `activity`, `alerts`, `ingestion` |
| `entity_type` | `user`, `role`, `opportunity`, `commit`, `quota`, `hierarchy`, `notification`, `tenant`, `crm_sync`, `etl_batch`, `external_signal`, `schema_mapping`, `glue_job` |
| `action` | 40 values |
| `status` | `success`, `failed`, `rejected`, `in_progress`, `failure` |
A `mode="before"` validator lowercases all six string fields *before* enum coercion. That is why a
service emitting `status: "SUCCESS"` is accepted while one emitting an unlisted `action` is not — case
is forgiven, vocabulary is not.
**An event that fails validation is not audited at all.** It fails in both consumers, retries, lands
in two DLQs, and is deleted after 14 days. Nothing consumes those DLQs and no alarm is wired to them.
## Redaction happens on the way in
Ten field-name fragments are matched case-insensitively and as substrings, against nested dicts and
lists of dicts: `password`, `secret`, `token`, `access_token`, `refresh_token`, `api_key`, `ssn`,
`credit_card`, `cvv`, `pin`. The list is overridable via `SENSITIVE_FIELDS`.
Matching is by **substring**, so `user_password_hash` is caught. It is also why a field named
`tokenCount` would be redacted — the check does not know what the value is.
Redaction is applied to `before` and `after` only, in both consumers, before anything is written.
There is no unredacted copy anywhere.
## Four ways to read it back
The record is written once and indexed four times, so each of these is a single query rather than a
scan:
| Question | Key | Index |
|---|---|---|
| What happened in this domain today? | `TENANT#…#DOMAIN#…#DAY#YYYYMMDD` | base table |
| What happened to this record? | `TENANT#…#ENTITY#{type}#{id}` | GSI1 |
| What did this person do? | `TENANT#…#ACTOR#{actor_id}` | GSI2 |
| What else happened in this run? | `TENANT#…#CORR#{correlation_id}` | GSI3 |
Every sort key is `TS#{occurred_at}`, so all four are naturally time-ordered and support both
pagination directions.
**GSI3 is the one worth knowing about.** The ingestion pipeline generates a run-wide
`correlation_id` and stamps it on every event it emits, so a single query returns the complete story
of one CRM sync — start, 29 table batches, and completion — in order. Correlation is optional
elsewhere: `gsi3_pk` is only set when the publisher supplied one.
## Things that are true and easy to miss
**Partitioning follows `occurred_at`, not arrival.** A late-delivered event lands in the partition and
S3 prefix for the day it happened. Backfilled events do not cluster at the end.
**Duplicates return 200.** `ConditionalCheckFailedException` is caught and logged as a duplicate. An
EventBridge redelivery is a no-op, not an error.
**The 90-day TTL is silent.** DynamoDB deletes expired records with no event and no notification.
After 90 days the S3 archive is the only copy, and there is no API in front of it — reading it means
reaching for the bucket directly.
**A rejected event is invisible.** No metric, no alarm, no dead-letter consumer. The commit service's
malformed `QuotaAssigned` payload — `entity_type: "commit"` with `action: "quotaassigned"`, which is
not in the allowed action list — is rejected here every time it fires, and nothing anywhere reports
that.
---
id: DealDeskEmailIngestionFlow
name: Deal Desk - Email Ingestion & Scheduled Data Sync Pipeline
version: 1.0.0
summary: End-to-end flow covering scheduled daily Gmail synchronization and initial backfill. Documents historyId delta tracking, AgentCore OAuth token resolution, SSE-KMS S3 corpus writes, ClickHouse indexing, watermark management, and EventBridge outcome events.
owners:
- revenue-intelligence
domain: DealDesk
steps:
- id: 1
externalSystem:
name: "AWS EventBridge Scheduler / Step Functions"
summary: "EventBridge Scheduler fires daily (rate 1 day) or is triggered immediately by GoogleConnectionEstablished event."
title: "Sync Run Triggered"
summary: "Two entry points: (A) Scheduled - EventBridge Scheduler fires daily and starts email_sync.asl.json. (B) Backfill - GoogleConnectionEstablished event triggers email_backfill.asl.json when a new user connects."
next_step: 2
- id: 2
service:
id: "DealDeskService"
version: "1.0.0"
title: "Resolve Connections and Watermarks"
summary: "Ingestion Lambda reads active OAuth connections from Postgres oauth_connection table, then fetches the last synced Gmail historyId cursor from email_sync_state. The historyId is the bookmark that tells Gmail which messages to return next."
next_step: 3
- id: 3
externalSystem:
name: "AWS Bedrock AgentCore Identity"
summary: "OAuth token vault storing Google access and refresh tokens per user."
title: "Fetch OAuth Access Tokens"
summary: "AgentCore Identity retrieves a valid Google OAuth access token per connected user. Raw tokens are never stored in Postgres - AgentCore is the only token store."
next_step: 4
- id: 4
externalSystem:
name: "Gmail API (Google Workspace)"
summary: "Source mailbox accessed via historyId delta or paginated list query."
title: "Fetch Email Messages from Gmail"
summary: "Queries Gmail API for new messages. Uses historyId for incremental daily syncs, or paginated newer_than:90d query for initial backfill. Messages already in the corpus are skipped."
next_step: 5
- id: 5
externalSystem:
name: "Amazon S3 (Email Corpus Bucket)"
summary: "Durable email corpus storage encrypted with SSE-KMS per-environment CMK."
title: "Write Raw Email Corpus to S3"
summary: "Normalizes raw email bodies and headers into a provider-agnostic JSON schema and writes to S3 bucket partitioned by tenant/year/month/day. Encryption enforced via bucket policy (TLS + KMS CMK)."
next_step: 6
- id: 6
externalSystem:
name: "ClickHouse (dd_corpus_index)"
summary: "Columnar analytics store indexing ingested email artifact metadata."
title: "Index Corpus Artifacts in ClickHouse"
summary: "Inserts message metadata row into ClickHouse dd_corpus_index. Deduplication is enforced on external_id so re-runs do not create duplicate index entries."
next_step: 7
- id: 7
externalSystem:
name: "Amazon RDS PostgreSQL and DynamoDB"
summary: "email_sync_state (Postgres) holds the historyId watermark. pipeline-idempotency (DynamoDB) guards against duplicate processing."
title: "Update Watermarks and Idempotency Lock"
summary: "Writes the latest Gmail historyId back to Postgres email_sync_state so the next scheduled run resumes from the right point. Records stage completion in DynamoDB pipeline-idempotency to prevent double-processing on Lambda retries."
next_step: 8
- id: 8
event:
id: "EmailIngestionSucceeded"
version: "1.0.0"
title: "Publish: EmailIngestionSucceeded"
summary: "EventBridge event (source: rio.core.activity) published upon successful message ingestion. Acts as an asynchronous trigger for downstream pipelines."
next_step: 9
- id: 9
externalSystem:
name: "AWS Step Functions (email-attachment-pipeline)"
summary: "State machine downloading and storing email attachments triggered by EmailIngestionSucceeded."
title: "Trigger Attachment Download Pipeline"
summary: "EventBridge rule matches EmailIngestionSucceeded event and launches email_attachment_pipeline.asl.json. Attachments are downloaded, stored in S3, and metadata recorded in Postgres email_attachment table."
---
import Footer from '@catalog/components/footer.astro';
# Deal Desk - Email Ingestion & Scheduled Data Sync Pipeline
This flow documents how customer emails are continuously ingested from Gmail into RIO's durable
data lake. Think of it as a daily pull-and-store job: RIO connects to each rep's Gmail,
grabs any new messages it has not seen, and saves them securely in S3 so the AI pipeline
can process them.
## Two Ways This Flow Starts
**1. Scheduled Daily Sync**
Every day (`rate 1 day`), an EventBridge Scheduler cron fires and starts the `email_sync`
Step Functions state machine. This is the routine daily top-up - only messages newer than
the last sync are fetched.
**2. Immediate Backfill**
The moment a sales rep first connects their Google account, a `GoogleConnectionEstablished`
event fires and triggers `email_backfill`. This pulls up to 90 days of historical emails
so the rep has deal context right away, not just from today.
## Step-by-Step Walk-Through
| Step | What Happens | Who Does It | Key Detail |
|------|---|---|---|
| 1 | Run triggered | EventBridge Scheduler or Step Functions | Daily cron OR GoogleConnectionEstablished event |
| 2 | Read connections and historyId | Deal Desk Service (Postgres) | historyId is the resume bookmark for Gmail |
| 3 | Get OAuth token for each user | AWS Bedrock AgentCore | Tokens never stored in Postgres |
| 4 | Fetch new emails from Gmail | Gmail API | historyId (incremental) or newer_than:90d (backfill) |
| 5 | Save raw emails to S3 | Amazon S3 Corpus Bucket | SSE-KMS encrypted, partitioned by tenant/date |
| 6 | Index metadata in ClickHouse | ClickHouse dd_corpus_index | Deduped on external_id |
| 7 | Update watermark and idempotency | Postgres + DynamoDB | historyId updated; DynamoDB blocks double-processing |
| 8 | Event: EmailIngestionSucceeded | EventBridge (rio.core.activity) | Domain event emitted on ingestion success; triggers attachment pipeline |
| 9 | Attachment pipeline starts | AWS Step Functions | Downloads attachments, stores in S3 |
## Additional Events Published (Not in Flow Diagram)
The ingestion Lambda also emits these outcome events on EventBridge:
| Event | When | Consumer |
|---|---|---|
| `EmailSyncSucceeded` | Full sync run completes without error | Audit Service catch-all only (no downstream trigger) |
| `EmailSyncFailed` | Sync exhausts retries (bad token, API quota) | Notification Service - ops alert to technical channels |
| `EmailIngestionFailed` | Message ingestion fails after retries | Notification Service - ops alert to technical channels |
Both failure events stamp `target_service = "rio.platform.notification"` in their payload so
the Notification Service routes them to the engineering alert channel automatically.
## Why historyId Matters
The Gmail `historyId` is a monotonically increasing cursor. Think of it like a page number -
once you have read up to page 400, next time you ask Gmail to start from page 401.
RIO stores this per user in Postgres `email_sync_state` so daily syncs only fetch new
messages and never re-process the entire mailbox.
## Ingestion Metrics and Schedules
| Metric | Value | Source |
|---|---|---|
| Sync Schedule | `rate 1 day` | EventBridge Scheduler launching email_sync.asl.json |
| Backfill Range | `newer_than:90d` | Current quarter plus prior quarter |
| Backfill Page Size | 100 messages | Paginated Gmail API fetch |
| Backfill Map Concurrency | 2 concurrent connections | Step Functions Map state |
| Retry Strategy | 60s / 120s throttle, 5m / 10m general | Exponential backoff before failure event |
| Encryption | SSE-KMS per-env CMK | S3 Bucket Policy enforces TLS + KMS |
---
id: DealDeskEmailIntelligencePipelineFlow
name: Deal Desk - End-to-End AI Enrichment & Deal Intelligence Pipeline
version: 1.0.0
summary: Multi-stage AI pipeline covering Stage 2 Parsing (Bedrock Converse 3-tier fallback), Stage 3 Attribution and 3-Lane Resolution, Stage 4 Distillation and 1024-dim Vector Embedding, Stage 5 Canonical Clustering and Pattern Synthesis, and Stage 6 On-Demand Deal Intelligence Serving.
owners:
- revenue-intelligence
domain: DealDesk
steps:
- id: 1
service:
id: "DealDeskService"
version: "1.0.0"
title: "AI Pipeline Orchestrator"
summary: "DealDeskService periodically triggers the multi-stage AI pipeline to process new emails."
next_step: 2
- id: 2
node:
id: "email_parsing_pipeline"
title: "Stage 2: Parse Email Body and Attachments"
summary: "Scheduled Step Functions state machine runs email_parsing_pipeline.asl.json using Amazon Bedrock Converse API. Applies 3-tier resilience fallback: Tier 1 all attachments, Tier 2 small attachments under 4MB only, Tier 3 body text only. Parsed output saved to S3 and Postgres parsed_email table."
next_step: 3
- id: 3
node:
id: "email_attribution_pipeline"
title: "Stage 3a: Person and Account Attribution"
summary: "Runs email_attribution_pipeline.asl.json. AddressResolver maps sender and recipient email addresses to CRM Person and Account records. Writes attribution facts to ClickHouse: dd_fact_artifact (per-message), dd_fact_artifact_participant (To/Cc/Bcc mappings), and dd_fact_thread (thread-level rollup)."
next_step: 4
- id: 4
node:
id: "email_resolution_pipeline"
title: "Stage 3b: 3-Lane Entity Resolution"
summary: "Runs email_resolution_pipeline.asl.json. Three evidence lanes: (1) CRM Lane - direct account/opportunity structural lookup, (2) LLM Lane - Claude extracts candidate entities from text, (3) Text Lane - regex matches product SKUs, region codes, deal size bands. Fusion Engine weighs confidence scores and resolves Opportunity, Product, Quote, and Region per thread. Written to Postgres thread_resolution and ClickHouse dd_fact_thread."
next_step: 5
- id: 5
node:
id: "email_distillation_pipeline"
title: "Stage 4: Distil Learnings and Generate 1024-dim Embeddings"
summary: "Runs email_distillation_pipeline.asl.json. Amazon Bedrock Claude extracts structured business learnings (pricing pushback, blockers, competitor mentions, positive signals) per resolved thread. Bedrock Titan Embeddings converts each learning into a 1024-dimensional vector. Both stored in ClickHouse dd_fact_thread_learning."
next_step: 6
- id: 6
event:
id: "EmbeddingsBatchReady"
version: "1.0.0"
title: "Publish EmbeddingsBatchReady"
summary: "After embedding a batch, publishes EmbeddingsBatchReady to EventBridge (source: rio.activity). Payload contains run_id and thread_learning_ids. If over 1,000 ids, they are offloaded to S3 and the event carries only an S3 pointer. Consumers must handle both payload shapes."
next_step: 7
- id: 7
node:
id: "canonical_learning_pipeline"
title: "Stage 5a: Canonical Learning via Vector Clustering"
summary: "EmbeddingsBatchReady triggers canonical_learning_pipeline.asl.json. Performs HNSW vector similarity search in ClickHouse to find near-duplicate learnings across threads. Similar learnings collapsed into a single canonical row in dd_canonical_learning. Collapse is deterministic via uuid5(slice + learning_id) to prevent duplicates on re-runs."
next_step: 8
- id: 8
node:
id: "pattern_synthesis_pipeline"
title: "Stage 5b: Slice Pattern Synthesis"
summary: "Scheduled pattern_synthesis_pipeline.asl.json uses Bedrock Claude to synthesize high-level deal patterns per slice (product x region x deal_type x deal_size_band) from canonical learnings. Results written to ClickHouse dd_synthesized_pattern - the company knowledge layer of what typically happens in deals for a given product in a given region."
next_step: 9
- id: 9
node:
id: "deal_intelligence_lambda"
title: "Stage 6: Serve Deal Intelligence On Demand"
summary: "API call POST /activity/deal-intelligence/{id}/generate triggers an async Lambda. Combines CRM opportunity data, quote revision history, and distilled thread learnings. Bedrock Claude synthesizes: MEDDPPICC scoring, pricing justification with net ask, deal timeline milestones, and quote revision graph. Result cached in Postgres deal_intelligence with 24-hour TTL."
---
import Footer from '@catalog/components/footer.astro';
# Deal Desk - End-to-End AI Enrichment & Deal Intelligence Pipeline
This flow shows how raw emails stored in S3 (from the Ingestion flow) are progressively
enriched by AI into high-value deal intelligence. Think of it as an assembly line - each
stage hands a refined output to the next.
> Every step is event-driven or scheduled via Step Functions
> state machines. No Lambda calls another Lambda directly. Each stage is independently
> retryable, and failures are published as EventBridge events.
## The Assembly Line
```
S3 Corpus (raw emails from Ingestion flow)
|
v
Stage 2: Parse (Bedrock Converse 3-tier fallback)
-- S3 parsed text + Postgres parsed_email
|
v
Stage 3a: Attribution (AddressResolver)
-- ClickHouse: dd_fact_artifact, dd_fact_artifact_participant, dd_fact_thread
|
v
Stage 3b: 3-Lane Resolution (CRM + LLM + Text Fusion)
-- Postgres: thread_resolution | ClickHouse: dd_fact_thread
|
v
Stage 4: Distil (Claude) + Embed (Titan 1024-dim)
-- ClickHouse: dd_fact_thread_learning
-- EventBridge: EmbeddingsBatchReady published
|
v
Stage 5a: Canonical Clustering (HNSW vector search in ClickHouse)
-- ClickHouse: dd_canonical_learning
|
v
Stage 5b: Pattern Synthesis (Bedrock Claude per slice)
-- ClickHouse: dd_synthesized_pattern
|
v
Stage 6: Deal Intelligence Serving (on-demand Lambda)
-- Postgres: deal_intelligence (24h cache TTL)
Output: MEDDPPICC + Pricing + Timeline + Quote Graph
```
## Pipeline Failure Events
Each stage publishes a failure event to EventBridge if it fails. All failure events use
`source: rio.activity` and land in the Audit Service catch-all. None trigger the
Notification Service (no `target_service` stamp) except the ingestion failures.
| Event | Stage | Who Consumes It |
|---|---|---|
| `EmailParsingFailed` | Stage 2 - Parsing | Audit Service only (future alerting hook) |
| `EmailResolutionFailed` | Stage 3b - Resolution | Audit Service only |
| `EmailResolutionSummary` | Stage 3b - Resolution | Audit Service only (run-level tally) |
| `EmailDistillationFailed` | Stage 4 - Distillation | Audit Service only |
| `EmailEmbeddingFailed` | Stage 4 - Embedding | Audit Service only |
| `PatternSynthesisFailed` | Stage 5b - Synthesis | Audit Service only |
## Detailed Stage Notes
### Stage 2 - Parsing Fallback Hierarchy
1. **Tier 1** - Parse body + all attachments
2. **Tier 2** - If content filter or token limit triggers, retry with attachments under 4MB
3. **Tier 3** - Body-only text extraction
4. **Failure** - Record in `parsed_email_failure`, emit `EmailParsingFailed`
### Stage 4 - EmbeddingsBatchReady Dual Payload
EventBridge has a 256 KB per-entry limit. Large batches exceed it. So:
- **Under 1,000 ids** - `thread_learning_ids` array sent inline in the event
- **Over 1,000 ids** - ids written to S3, event carries only an `s3_payload_key` pointer
A consumer reading only `thread_learning_ids` will silently miss large batches.
### Stage 5 - What is a Canonical Learning?
If 50 different email threads all say "customer mentioned budget concerns", that is one
business insight appearing 50 times. HNSW vector similarity search finds these near-duplicates
and collapses them into one `dd_canonical_learning` row so the pattern synthesis stage
works on unique insights, not repeated noise.
### Stage 6 - What MEDDPPICC Means
A sales qualification framework. Scoring on each dimension tells the deal desk reviewer
how qualified the deal actually is:
- **M** Metrics, **E** Economic Buyer, **D** Decision Criteria, **D** Decision Process
- **P** Paper Process, **P** Identify Pain, **I** Champion, **C** Competition
---
id: DealDeskOAuthConnectionFlow
name: Deal Desk - Google OAuth Connection & Automatic Backfill Trigger
version: 1.0.0
summary: End-to-end workflow covering user Google account authorization through AWS Bedrock AgentCore Identity, stateless HMAC callback verification, Postgres connection registration, EventBridge GoogleConnectionEstablished event publishing, and automated backfill triggering.
owners:
- revenue-intelligence
domain: DealDesk
steps:
- id: 1
actor:
name: "Sales Representative"
summary: "A sales rep clicks 'Connect Google Account' within the RIO web application."
title: "Initiate Connection"
summary: "The sales rep starts the Google account linking process from the RIO settings UI."
next_step: 2
- id: 2
service:
id: "DealDeskService"
version: "1.0.0"
title: "Request Authorization URL"
summary: "RIO Web App calls POST /activity/integrations/google/connections/{person_id}/authorize on DealDeskService. The service signs a short-lived HMAC state token (connection id + tenant id + expiry + nonce)."
next_step: 3
- id: 3
externalSystem:
name: "AWS Bedrock AgentCore Identity"
summary: "OAuth token vault and workload identity service."
title: "Generate Consent URL"
summary: "DealDeskService calls AgentCore Identity API with the HMAC state as customState, returning the official Google authorization URL to the client."
next_step: 4
- id: 4
externalSystem:
name: "Google OAuth 2.0 Provider"
summary: "Google OAuth consent screen for Gmail/Calendar scopes."
title: "User OAuth Consent"
summary: "The user authenticates with Google and consents to read-only Gmail scopes. Google redirects the user back to DealDeskService callback URL with customState."
next_step: 5
- id: 5
service:
id: "DealDeskService"
version: "1.0.0"
title: "Handle OAuth Callback"
summary: "FastAPI endpoint GET /activity/integrations/google/callback validates the HMAC state signature statelessly without needing session lookup tables."
next_step: 6
- id: 6
externalSystem:
name: "Amazon RDS (PostgreSQL)"
summary: "Transactional database owning the oauth_connection table."
title: "Store Connection State"
summary: "Completes token registration in AgentCore vault and executes an atomic UPDATE on Postgres oauth_connection setting status='success' and connected_at timestamp."
next_step: 7
- id: 7
event:
id: "GoogleConnectionEstablished"
version: "1.0.0"
title: "Publish Connection Event"
summary: "On the first successful connect transition, emits GoogleConnectionEstablished event to EventBridge bus (rio.activity)."
next_step: 8
- id: 8
externalSystem:
name: "AWS Step Functions (email-backfill)"
summary: "State machine orchestrating 90-day paginated email backfill for newly connected accounts."
title: "Trigger Instant Backfill"
summary: "EventBridge rule ${DeployPrefix}-email-backfill-on-connect matches the event and launches email-backfill state machine to pull historical emails (newer_than:90d)."
---
import Footer from '@catalog/components/footer.astro';
# Deal Desk - Google OAuth Connection & Automatic Backfill Trigger
This flow documents the exact sequence of actions when a user links their Google account to RIO.
## What happens (in plain terms)
1. A sales rep clicks **Connect Google Account** in RIO.
2. The **Deal Desk Service** generates a tamper-proof, short-lived HMAC state token and requests a consent link from **AWS Bedrock AgentCore Identity** (the OAuth token vault).
3. The rep approves permissions on Google's consent screen.
4. Google redirects back to RIO's callback endpoint. The service verifies the HMAC token, stores the connection in AgentCore and PostgreSQL (`oauth_connection`), and publishes the `GoogleConnectionEstablished` event on EventBridge.
5. An EventBridge rule immediately launches the **Email Backfill** pipeline, pulling up to 90 days of historical emails so the rep's deal intelligence is populated right away.
## Step-by-Step Overview
| # | Step | Component | Description |
|---|------|-----------|-------------|
| 1 | **Initiate Connection** | Sales Representative | User clicks "Connect Google Account" in the UI. |
| 2 | **Request Authorization URL** | Deal Desk Service | `POST /authorize` call generates signed HMAC state token. |
| 3 | **Generate Consent URL** | Bedrock AgentCore | Requests authorization URL with `customState`. |
| 4 | **User OAuth Consent** | Google OAuth | Rep grants read-only Gmail permissions. |
| 5 | **Handle OAuth Callback** | Deal Desk Service | `GET /callback` statelessly verifies HMAC signature. |
| 6 | **Store Connection State** | Amazon RDS Postgres | Vault token stored & atomic DB update to `oauth_connection`. |
| 7 | **Publish Connection Event** | `GoogleConnectionEstablished` | Event published to `{env}-rio-events` bus. |
| 8 | **Trigger Instant Backfill** | AWS Step Functions | EventBridge rule launches `email_backfill.asl.json`. |
## Architecture & Security Details
- **Stateless HMAC Correlation**: The OAuth callback requires no database session lookup. The state parameter contains an HMAC-SHA256 signature combining connection UUID, tenant ID, expiry timestamp, and a random nonce.
- **Credential Storage**: Raw OAuth access/refresh tokens are stored **exclusively in AWS Bedrock AgentCore Identity**. Deal Desk code never stores OAuth secrets in Postgres or plaintext environment variables.
- **Event-Driven Backfill**: Publishing `GoogleConnectionEstablished` ensures that initial data synchronization is decoupled from HTTP request lifecycle.
---
id: ForecastSubmissionFlow
name: Forecast Submission & Rollup
version: 1.0.0
summary: "Collaborative forecasting lifecycle — rep submits commits, Lambda Auth validates, CommitService processes rollups, managers review and adjust."
owners:
- revenue-intelligence
steps:
- id: 1
actor:
name: "Sales Rep"
summary: "Individual contributor submitting their quarterly forecast commitment."
title: "Submit Forecast Commits"
summary: "Rep enters commit, upside, and best-case values for their assigned opportunities in the RIO UI."
next_step: 2
- id: 2
service:
id: "AppLayerService"
version: "1.0.0"
title: "API Gateway Authorization"
summary: "Lambda Authorizer validates the Cognito JWT, confirms identity, and forwards the request with auth context."
next_step: 3
- id: 3
service:
id: "CommitService"
version: "1.0.0"
title: "Forecast Processing & Hierarchy Rollup"
summary: "Validates submission cadence, persists forecast_submission and forecast_revision records, computes live rollups up the person hierarchy tree."
next_step: 4
- id: 4
message:
id: "CommitSubmitted"
version: "1.0.0"
title: "Commit Submitted Event"
summary: "Published to EventBridge, enabling downstream consumers (analytics, notifications) to react to the new commitment."
next_step: 5
- id: 5
actor:
name: "Sales Manager"
summary: "Manager reviewing the aggregated team forecast and applying overrides."
title: "Manager Review & Override"
summary: "Manager views the rolled-up team numbers, applies adjustments or overrides, and submits the revised forecast for their node."
---
# Forecast Submission & Rollup
This flow documents the weekly and quarterly collaborative forecasting cycle in the RIO Platform.
## Lifecycle
```
Rep → API Gateway (Auth) → Commit Service (Process + Rollup) → EventBridge → Manager Review
```
### Submission
Sales reps submit their forecast values (commit, upside, best-case) through the RIO UI. The request passes through the Lambda Authorizer for JWT validation before reaching the CommitService.
### Rollup
The CommitService validates the submission timing against the fiscal cadence, persists
`fact_forecast_submission` and `fact_forecast_revision` records, and computes rollups by traversing
the person hierarchy tree upward. The traversal uses the flattened `level_1_id` … `level_10_id`
columns on Person rather than a recursive walk.
### Manager Adjustment
Managers review the aggregated numbers for their reporting node and can apply overrides or
adjustments before the forecast is finalized for the cycle. An adjustment is stored as a new
`fact_forecast_revision` row, so the rep's original submission is never overwritten.
## What the events actually carry
A single submit call emits **two** events —
Commit Submitted and
Commit Finalized — and an adjustment
emits Commit Adjusted.
Two things to know before building on them:
1. **The amounts are not in the event.** The service drops its rich domain fields before publishing
and sends only an audit envelope plus the submission id. A consumer that needs the committed
figure has to call the API back.
2. **Nothing subscribes to them.** No EventBridge rule anywhere in the workspace listens for commit
events specifically. They reach the audit trail through the
Audit Service's catch-all rule, and
nothing else reacts to them.
## Automatic submissions are silent
If a rep never submits, `${DeployPrefix}-auto-submit` submits on their behalf — Monday 00:01 UTC for
submissions, Tuesday 00:01 UTC for manager adjustments
(`rio-commit-service/infrastructure/lambda/template.yaml:102-117`).
**That Lambda publishes no events.** `EventBusName` is passed to the ECS module but not to the Lambda
module (`infrastructure/template.yaml:257-280`), so it has no `EVENT_BUS_NAME` to publish to. A
forecast auto-submitted by the scheduler looks identical in the database to one a rep submitted, but
produces no `Commit Submitted` event and leaves no audit record.
## If reps do not submit
Two cron schedules act as a backstop rather than any event-driven escalation:
`AutoSubmitSchedule` runs Monday 00:01 UTC and `AutoAdjustSchedule` Tuesday 00:01 UTC. Separately,
the Notification Service sends
reminder nudges on Thursday and Friday.
---
id: HierarchyRecomputation
name: Org Hierarchy Recomputation
version: 1.0.0
summary: "The platform's one true cross-service event chain — a manager change in the CRM or an admin edit in the API fires Hierarchy Updated, and an Identity Service Lambda recomputes the flattened level_1…level_10 columns that forecast rollups and subtree visibility both depend on."
owners:
- revenue-intelligence
steps:
- id: 1
service:
id: "DataIngestionService"
version: "1.0.0"
title: "CRM manager change detected"
summary: "While processing crm_systemuser_history, the pipeline compares each user's manager_source_id against current RDS state and collects everyone whose manager changed."
next_step: 3
- id: 2
service:
id: "IdentityHierarchyService"
version: "1.0.0"
title: "Admin edits a reporting line"
summary: "An admin reassigns reporting relationships through the API. The service writes a hierarchy_revision row in PROCESSING status before publishing."
next_step: 4
- id: 3
message:
id: "HierarchyUpdatedByIngestion"
version: "1.0.0"
title: "Event: from the CRM"
summary: "source rio.glue.crm_sync, provisioning_source CRM, batched 1,000 changes per put_events call, sorted top-down by hierarchy depth."
next_step: 5
- id: 4
message:
id: "HierarchyUpdated"
version: "1.0.0"
title: "Event: from the API"
summary: "source rio.api.hierarchy_change, provisioning_source MANUAL, carries a hierarchy_revision_id. Batched to stay under the EventBridge entry size limit."
next_step: 5
- id: 5
custom:
title: "EventBridge rule"
icon: "FunnelIcon"
type: "AWS EventBridge"
color: "purple"
summary: "One rule matches both publishers. All three conditions must hold or the Lambda is never invoked."
properties:
Rule: "HierarchyDomainEventRoute"
source: "rio.glue.crm_sync, rio.api.hierarchy_change"
detail-type: "Hierarchy Updated"
detail.event_name: "rio.user.hierarchy.updated"
Defined at: "infrastructure/lambda/template.yaml:168-180"
title: "Route to the Lambda"
summary: "The rule filters on source, detail-type AND detail.event_name. A publisher that gets any one of the three wrong is silently ignored."
next_step: 6
- id: 6
custom:
title: "Serialize per tenant"
icon: "LockClosedIcon"
type: "AWS Lambda"
color: "green"
summary: "Two concurrent recomputations for one tenant would race on the same rows, so the work is serialized in Postgres."
properties:
Function: "${DeployPrefix}-hierarchy-updates"
Runtime: "python3.12 / arm64 / 1024 MB / 900 s"
Concurrency: "ReservedConcurrentExecutions: 25"
Lock: "pg_advisory_xact_lock(hashtext(tenant_id), hashtext('hierarchy'))"
Retry: "5 attempts, exponential 2-10 s, on OperationalError only"
title: "Acquire the tenant lock"
summary: "A transaction-scoped Postgres advisory lock keyed on the tenant. Everything after this runs inside that one transaction."
next_step: 7
- id: 7
custom:
title: "Protected-source guard"
icon: "ShieldExclamationIcon"
type: "Business rule"
color: "yellow"
summary: "A human edit outranks the CRM. This is the rule that stops the next hourly sync from undoing an admin's work."
properties:
PROTECTED_SOURCES: "MANUAL (default)"
Rule: "skip if the row's current source is protected and the incoming one is not"
API normalisation: "provisioning_source API is rewritten to MANUAL"
Code: "services/hierarchy_service.py:201-217"
title: "Decide what may be overwritten"
summary: "Each update is checked against the row's existing provisioning_source. Unknown person ids are dropped here too."
next_steps:
- id: 8
label: "allowed"
- id: 13
label: "protected / unknown"
- id: 8
custom:
title: "Blast radius"
icon: "ArrowsPointingOutIcon"
type: "Graph traversal"
color: "blue"
summary: "Far more rows change than the ones named in the event — the whole subtree moves, and two separate manager chains shift their headcounts."
properties:
Subtree: "every descendant of each moved person"
Old ancestors: "the previous manager and everyone above (reportee count drops)"
New ancestors: "the new manager and everyone above (reportee count rises)"
Also computed: "subtree_sizes → no_of_reportees per person"
title: "Work out who is affected"
summary: "One person moving can rewrite thousands of rows. The affected set is the union of the moved subtrees plus both old and new ancestor chains."
next_step: 9
- id: 9
custom:
title: "Recompute the chain"
icon: "Bars3BottomLeftIcon"
type: "Hierarchy calculation"
color: "blue"
summary: "The manager chain is walked upward and flattened into fixed columns so downstream queries never need a recursive CTE."
properties:
Output: "level_1_id … level_10_id + hierarchy_path"
Depth overflow: "chains beyond 10 keep the LOWEST 10 levels; the full path survives in hierarchy_path"
Cycles: "detected via a visited set, logged as a warning, traversal stops"
Memoisation: "per-person cache reused across the affected set"
title: "Flatten level_1 … level_10"
summary: "Produces the columns that CommitService rollups and can_view_subtree both read."
next_step: 10
- id: 10
custom:
title: "Write to Postgres"
icon: "CircleStackIcon"
type: "Amazon RDS (PostgreSQL)"
color: "gray"
summary: "The only datastore this Lambda actually writes to, despite what its own description claims."
properties:
Table: "person"
Written: "manager_source_id, level_1_id … level_10_id, hierarchy_path, no_of_reportees, provisioning_source"
Chunking: "CHUNK_SIZE 5,000 rows per read"
Transaction: "same one that holds the advisory lock"
title: "Persist the new hierarchy"
summary: "Commits inside the locked transaction, so either the whole recomputation lands or none of it does."
next_steps:
- id: 11
label: "committed"
- id: 12
label: "raised"
- id: 11
custom:
title: "Revision goes ACTIVE"
icon: "CheckCircleIcon"
type: "State transition"
color: "green"
summary: "Only for the API path — the CRM path carries no revision id, so nothing is marked."
properties:
Table: "hierarchy_revision"
Set: "status = ACTIVE, is_current = true"
Guard: "WHERE status = 'PROCESSING' — will not clobber a concurrent change"
title: "Publish the revision"
summary: "The revision the API created in PROCESSING becomes the current org chart."
- id: 12
custom:
title: "Revision marked failed"
icon: "XCircleIcon"
type: "State transition"
color: "red"
summary: "The exception is re-raised after the status write, so Lambda records the invocation as failed."
properties:
Set: "status = HIERARCHY_UPDATE_FAILED"
CRM path: "no revision id — a CRM failure leaves no trace in hierarchy_revision"
Retry: "EventBridge's default async retry, then the event is dropped"
title: "Recomputation failed"
summary: "RDS rolls back with the transaction. The org chart is unchanged, but the CRM path records nothing anywhere."
- id: 13
custom:
title: "Silently skipped"
icon: "NoSymbolIcon"
type: "No-op"
color: "gray"
summary: "Returns 200 with 'No valid users to update.' — indistinguishable from a successful run in metrics."
properties:
Causes: "manually-set row + CRM-sourced change, or a person id not present in the tenant"
Emitted: "nothing"
title: "Change discarded"
summary: "No event, no error, no audit record. The only evidence is a CloudWatch log line."
---
# Org Hierarchy Recomputation
The org hierarchy is the manager chain, flattened onto every person as `level_1_id` … `level_10_id`
plus a `hierarchy_path`. Almost everything that answers *"who reports to this manager"* reads those
columns rather than walking the tree — including
forecast rollups and the
`can_view_subtree` RBAC rule.
This flow is what keeps those columns correct. It is also **the only genuine cross-service event
chain in the platform**: one service publishes, a different service's Lambda consumes, and the
consumer's work is load-bearing for a third.
> **Tip:** hover a node in the diagram for its full text, or use **Start (walk through business
> flow)** to step through the branches.
## Two publishers, one consumer
| | CRM path | API path |
|---|---|---|
| Publisher | Data Ingestion Service | Identity Hierarchy Service |
| `source` | `rio.glue.crm_sync` | `rio.api.hierarchy_change` |
| `provisioning_source` | `CRM` | `MANUAL` (normalised from `API`) |
| Actor | `{type: system, id: glue_crm_sync_job}` | `{type: USER, id: }` |
| Tenant key | `context.tenant_id` (snake_case) | `context.tenantId` (camelCase) |
| Batching | 1,000 updates per call | sized to the EventBridge entry limit |
| Revision tracking | none | `hierarchy_revision_id` in `data` |
| On publish failure | logged, run continues | raises after 3 attempts |
Both use `detail-type: "Hierarchy Updated"` and `detail.event_name: "rio.user.hierarchy.updated"`,
which is what lets a single rule serve both.
**The tenant key differs in case between the two publishers.** `parse_event_payload` accepts
`tenantId`, `tenant_id`, and both at the top level, so this works today — but it works because the
parser is forgiving, not because the contract is agreed.
## The protected-source rule
This is the most consequential line of business logic in the flow, and it is four lines long:
```
skip when current_source ∈ PROTECTED_SOURCES and incoming_source ∉ PROTECTED_SOURCES
```
`PROTECTED_SOURCES` defaults to `MANUAL`. So:
- An admin's manual reassignment **survives** every subsequent CRM sync.
- A CRM change **is applied** to any row the CRM last touched.
- A manual change **overwrites** a CRM-set row.
Because API-sourced updates are rewritten from `API` to `MANUAL` before the check, an API edit is
always treated as manual and always wins over the CRM.
## One person moves, thousands of rows change
The event names only the people whose manager changed. The Lambda expands that to:
1. **The subtree** — every descendant of each moved person, because their whole `level_*` chain shifts.
2. **The old ancestors** — the previous manager and everyone above them, whose `no_of_reportees` drops.
3. **The new ancestors** — the new manager and everyone above them, whose `no_of_reportees` rises.
Moving a director with 400 people beneath them rewrites 400 rows plus two full management chains.
This is why the function is provisioned at 900 s and 1 GB, and why concurrency is capped at 25.
## Depth beyond ten levels
`calculate_hierarchy` walks upward until it runs out of managers. If the chain is longer than ten
links, **the top-most levels are discarded, not the bottom ones** — `level_1_id` … `level_10_id`
always describe the ten levels nearest the person. The complete chain is preserved in
`hierarchy_path`.
Anything that reads `level_1_id` as "the CEO" is wrong for deep organisations. Read `hierarchy_path`
if you need the root.
Cycles are handled rather than prevented: a `visited` set stops the walk, logs a warning, and the
partial path is used.
## Serialization
Every recomputation for a tenant runs inside `pg_advisory_xact_lock(hashtext(tenant_id),
hashtext('hierarchy'))`. Two events for the same tenant queue behind each other; two events for
different tenants run in parallel.
Because the lock is *transaction*-scoped, it is released by the commit or rollback — there is no
explicit unlock, and a crashed invocation cannot strand the lock.
Only `OperationalError` is retried (5 attempts, exponential 2–10 s). A data error is not retried; it
propagates and fails the invocation.
## What this flow does not do
**It publishes nothing.** There is no `put_events` call anywhere in the Lambda. Downstream consumers
that need to know the hierarchy changed have no signal to subscribe to — they read `person` and
discover it. The chain ends here.
**It does not write ClickHouse.** Three things claim otherwise and all three are stale:
| Claim | Where |
|---|---|
| "writes hierarchy change history to ClickHouse" | the Lambda's own CloudFormation `Description` |
| "Generated change payloads for RDS and ClickHouse" | the `ChangePayloads` docstring |
| `clickhouse-connect>=1.0.1` | `lambda/hierarchy-update/requirements.txt` |
`ChangePayloads` has exactly one field — `rds_updates` — and no ClickHouse client is ever
constructed. The dependency ships in the bundle unused.
## Failure behaviour, by path
| | CRM path | API path |
|---|---|---|
| Publish fails | logged, sync continues, change lost | raises `HierarchyProcessingError` |
| Rule does not match | invisible | invisible |
| Protected-source skip | 200 "No valid users to update." | 200 "No valid users to update." |
| RDS failure | invocation fails, EventBridge retries then drops | revision → `HIERARCHY_UPDATE_FAILED`, then re-raised |
The asymmetry matters: **a CRM-sourced hierarchy change that fails leaves no durable record
anywhere.** The API path at least parks a `HIERARCHY_UPDATE_FAILED` row in
hierarchy_revision for someone to
find.
## Why it matters downstream
`CommitService` computes forecast rollups by reading the flattened `level_1_id` … `level_10_id`
columns on Person rather than traversing the
tree. Those columns are only ever written here.
If this Lambda stops running, nothing breaks loudly. Forecast rollups keep returning numbers — they
just roll up to the *previous* org chart, and the `can_view_subtree` permission keeps granting access
based on it too.
---
id: NotificationDispatch
name: Notification Dispatch & Forecast Reminders
version: 1.0.0
summary: "Two unrelated paths into one Lambda — weekly cron reminders that chase reps and managers for missing forecasts, and operational alerts routed not by source or detail-type but by a field inside the payload."
owners:
- revenue-intelligence
steps:
- id: 1
custom:
title: "Three weekly schedules"
icon: "ClockIcon"
type: "EventBridge Scheduler"
color: "orange"
summary: "Each schedule passes a different notify_target, which is the only thing that distinguishes the three runs."
properties:
Thursday 12:00 UTC: "${DeployPrefix}-user-reminder → notify_target: user"
Friday 12:00 UTC: "${DeployPrefix}-manager-reminder → notify_target: manager"
Friday 16:00 UTC: "${DeployPrefix}-manager-adjustment-reminder → notify_target: manager_no_adjustment"
Defined at: "infrastructure/template.yaml:465-495"
title: "The reminder cadence fires"
summary: "Reps are chased on Thursday. Managers are chased twice on Friday — once for their own submission, once for adjustments they have not made."
next_step: 2
- id: 2
custom:
title: "forecast-notification Lambda"
icon: "MagnifyingGlassIcon"
type: "AWS Lambda"
color: "green"
summary: "Queries Postgres for who has NOT submitted, rather than reacting to anything. This path is a poll, not a subscription."
properties:
Function: "${DeployPrefix}-forecast-notification"
Runtime: "python3.12 / arm64 / 256 MB / 120 s"
Reads: "forecast_submission, person — NOT EXISTS for the current fiscal week"
Session: "SELECT app.set_session_system_admin() before querying"
PII: "names and emails resolved from the DynamoDB token vault"
DLQ: "${DeployPrefix}-forecast-notification-dlq"
title: "Find who is delinquent"
summary: "Builds the delinquent-user or delinquent-manager set for the current fiscal quarter and week, then produces two independent outputs per person."
next_steps:
- id: 3
label: "in-app"
- id: 4
label: "outbound"
- id: 3
custom:
title: "Notification inbox row"
icon: "InboxIcon"
type: "Amazon DynamoDB"
color: "gray"
summary: "The in-app notification centre. Written directly by the forecast Lambda — it does not go through the dispatch path at all."
properties:
Table: "NOTIFICATION_INBOX_TABLE"
Carries: "severity, fiscal quarter/week, action_url deep-link"
Read by: "the FastAPI notification-centre API (ECS)"
title: "Write the in-app item"
summary: "This is the copy the user sees inside RIO, and it is written before any email is sent."
next_step: 5
- id: 4
message:
id: "CreateNotification"
version: "1.0.0"
title: "Ask for delivery"
summary: "source rio.forecast-notification, detail-type Create Notification. Carries the subject, body and the channel list to deliver on."
next_step: 8
- id: 5
message:
id: "NotificationSent"
version: "1.0.0"
title: "Audit the inbox write"
summary: "Published by the forecast Lambda for the inbox insert — NOT by the dispatch Lambda, and not evidence that anything was actually delivered."
- id: 6
externalSystem:
name: "Deal Desk Service"
summary: "The primary service managing Gmail ingestion and AI parsing. Can emit failure events."
title: "A pipeline stage fails"
summary: "The email ingestion Lambda hits an error during Gmail sync or corpus ingestion."
next_step: 7
- id: 7
message:
id: "EmailIngestionFailed"
version: "1.0.0"
title: "Failure alert"
summary: "Stamps detail.target_service = 'rio.platform.notification' into the payload. EmailSyncFailed does the same."
next_step: 8
- id: 8
custom:
title: "Two rules, one function"
icon: "FunnelIcon"
type: "AWS EventBridge"
color: "purple"
summary: "The two rules match on completely different things, and neither filters on source."
properties:
CreateNotificationRule: "detail-type: Create Notification — no source filter"
NotificationRule: "detail.target_service + detail.event_name — no source, no detail-type"
Target: "${DeployPrefix}-notification-service (both)"
Defined at: "infrastructure/template.yaml:398-416"
title: "Route to the dispatcher"
summary: "Any service on the bus can emit Create Notification and have it delivered. That is the intended integration point."
next_step: 9
- id: 9
custom:
title: "notification-service Lambda"
icon: "PaperAirplaneIcon"
type: "AWS Lambda"
color: "green"
summary: "First line of the handler branches on target_service, and the two branches behave very differently."
properties:
Branch test: "detail.target_service == 'rio.platform.notification'"
Alert branch: "recipients from TECHNICAL_EMAILS + ALERT_CHANNELS env vars"
Alert guard: "returns ignored:true unless ENVIRONMENT == 'prod'"
Create branch: "recipients come from the event payload"
title: "Pick the branch"
summary: "Alerts build their own recipient list from configuration; Create Notification trusts whatever the publisher sent."
next_step: 10
- id: 10
custom:
title: "Send and log"
icon: "ChatBubbleLeftRightIcon"
type: "Channel registry"
color: "blue"
summary: "Two channels are registered. Body text is reformatted per channel before sending."
properties:
Registered: "SES, GOOGLE_CHAT"
Defined but unregistered: "EMAIL — would raise if requested"
Log: "one NOTIFICATION_LOG_TABLE row per recipient, sent or failed"
Failure mode: "a channel error is recorded as FAILED, not raised"
title: "Deliver"
summary: "Every recipient gets a DynamoDB log row regardless of outcome, so delivery history survives even when sending does not."
next_steps:
- id: 11
label: "alert → SES"
- id: 12
label: "alert → Chat"
- id: 13
label: "Create Notification"
- id: 11
message:
id: "NotificationSentToTechnicalEmails"
version: "1.0.0"
title: "Alert audited (email)"
summary: "status SUCCESS only when zero recipients failed and at least one was attempted."
- id: 12
message:
id: "NotificationSentToAlertChannels"
version: "1.0.0"
title: "Alert audited (chat)"
summary: "The Google Chat equivalent, published per channel rather than per recipient."
- id: 13
custom:
title: "Nothing published"
icon: "SpeakerXMarkIcon"
type: "Silent path"
color: "red"
summary: "dispatch() returns counts to the caller and exits. There is no put_events on this branch."
properties:
Emitted: "nothing"
Only record: "the NOTIFICATION_LOG_TABLE rows"
Consequence: "no audit trail entry for the forecast reminders that actually went out"
title: "The reminder path ends here"
summary: "The busiest path through this service is the one that leaves no event behind."
---
# Notification Dispatch & Forecast Reminders
The Notification Service is
three deployables in one repository, and this flow is where two of them meet: a **forecast reminder
Lambda** that polls Postgres on a cron, and a **dispatch Lambda** that fans messages out to SES and
Google Chat.
They are joined by an event the service publishes to itself.
> **Tip:** hover a node in the diagram for its full text, or use **Start (walk through business
> flow)** to step through both entry paths.
## Two entries, one dispatcher
```
cron ──▶ forecast Lambda ──(Create Notification)──▶ dispatch Lambda ──▶ SES / Google Chat
▲
activity pipeline failure ──(target_service in payload)───┘
```
Both rules point at `${DeployPrefix}-notification-service`. Everything else about them differs.
| | `CreateNotificationRule` | `NotificationRule` |
|---|---|---|
| Matches on | `detail-type: Create Notification` | `detail.target_service` + `detail.event_name` |
| `source` filter | none | none |
| `detail-type` filter | yes | **none** |
| Recipients from | the event payload | `TECHNICAL_EMAILS` / `ALERT_CHANNELS` env vars |
| Runs outside prod | yes | **no** |
## Routing by payload contents
`NotificationRule` does not match on `source` or `detail-type` at all. It matches on two fields
*inside* `detail`:
```
detail.target_service = "rio.platform.notification"
detail.event_name ∈ { rio.core.activity.emailingestionfailed,
rio.core.activity.emailsyncfailed }
```
The publisher is the Deal Desk Service, which stamps `target_service` into its own payload (`lambdas/email_ingestion/events.py:292-293`).
**This makes `event_name` a wire contract, not a label.** The rule enumerates two exact strings. Rename
either one and alerting stops — no error, no failed deployment, the rule simply never matches again.
Adding a third failure event requires editing this rule too; the Lambda's own
`_is_supported_platform_event` check enumerates the same names a second time.
**Alerts are prod-only.** `handle_platform_alert_event` returns `ignored: true, reason:
non_prod_environment` whenever `ENVIRONMENT != "prod"`. A pipeline failure in dev or QA reaches the
Lambda, is logged, and goes no further. This is deliberate, but it means the alert path is never
exercised before it is needed.
## The reminder cadence
| Schedule | `notify_target` | Who is chased |
|---|---|---|
| Thursday 12:00 UTC | `user` | reps with no `forecast_submission` for the current fiscal week |
| Friday 12:00 UTC | `manager` | managers who have not submitted |
| Friday 16:00 UTC | `manager_no_adjustment` | managers who submitted but adjusted nothing |
This is a **poll, not a subscription.** The Lambda runs `NOT EXISTS` queries against
`forecast_submission` for the current fiscal quarter and week. Nobody publishes "a forecast is
missing" — missingness is discovered on a timer.
That timer sits alongside, and is unaware of, the two auto-submit crons described in the
Forecast Submission & Rollup
flow (Monday 00:01 and Tuesday 00:01 UTC). The reminders fire Thursday and Friday; the auto-submit
that makes them moot fires the following Monday.
Names and email addresses are not in Postgres in the clear — they are resolved through the same
DynamoDB token vault the ingestion pipeline writes to, so a reminder email depends on the
tokenization vault being reachable.
## Two outputs per delinquent person
The forecast Lambda produces both, independently:
1. **An inbox row** written straight to `NOTIFICATION_INBOX_TABLE` — the in-app notification centre,
carrying severity, fiscal period and a deep-link `action_url`. This never touches the dispatch
Lambda.
2. **A `Create Notification` event** — which does.
They can diverge. If the event fails to publish, the user still sees the in-app item; if the inbox
write fails, the email still goes out.
## Which events are actually published
This is the part most likely to mislead, so it is worth stating plainly:
| Event | Published by | On what |
|---|---|---|
| `Create Notification` | forecast Lambda | every delinquent person |
| `Notification Sent` | **forecast Lambda** | the **inbox row insert** |
| `Notification Sent To Technical Emails` | dispatch Lambda | alert branch only |
| `Notification Sent To Alert Channels` | dispatch Lambda | alert branch only |
| `Notification Updated` | the FastAPI API (ECS) | a user reading or dismissing an item |
Two consequences:
**`Notification Sent` does not mean anything was sent.** It is published at
`forecast_notification/service.py:860`, at the point an inbox row is written to DynamoDB, before and
independently of any delivery attempt. Nothing about SES or Google Chat is known when it fires.
**The `Create Notification` branch publishes nothing at all.** `dispatch()` sends, writes a
`NOTIFICATION_LOG_TABLE` row per recipient, returns counts, and exits — no `put_events`. Only
`handle_platform_alert_event` calls `_publish_platform_alert_audit_events`.
So the highest-volume path through this service — the weekly forecast reminders that go to every
delinquent rep and manager — leaves **no entry in the audit trail**. Whether an email was delivered
is knowable only from the DynamoDB delivery log, which has no API and no
audit capture behind it.
## Channels
`CHANNEL_REGISTRY` holds two providers:
| `ChannelType` | Registered | Body formatting |
|---|---|---|
| `SES` | yes | HTML |
| `GOOGLE_CHAT` | yes | Chat card text |
| `EMAIL` | **no** | — |
`EMAIL` is defined on the enum and treated as email-like by the audit-event builder, but it is not in
the registry. A `Create Notification` requesting `email` rather than `ses` raises on channel lookup.
Nothing validates the channel name before dispatch, so the failure surfaces at send time.
A channel failure is recorded as `FAILED` on each recipient's log row and does not raise. One dead
channel therefore does not stop the other, and the invocation still reports success.
---
id: RioIngestionService
name: RIO Ingestion Service - CRM Data Sync Pipeline
version: 1.0.0
summary: Scheduled, incremental pipeline that extracts 29 tables from Actian CRM365 over JDBC, transforms and vaults PII, and fans the result out to S3 (bronze archive), ClickHouse (analytics, via S3Queue) and RDS PostgreSQL (operational upsert). Publishes an EventBridge audit event at every stage.
owners:
- revenue-intelligence
domain: PlatformServices
steps:
- id: 1
externalSystem:
name: "AWS EventBridge Scheduler / Step Functions"
summary: "A cron schedule starts the crm-data-sync."
title: "Sync Run Triggered"
summary: "Runs every hour in production and every 6 hours in other environments. "
next_step: 2
- id: 2
service:
id: "DataIngestionService"
version: "1.0.0"
title: "Bootstrap and Resolve Watermarks"
summary: "The service starts up, loads passwords and connection details, connects to all required services (S3, DynamoDB, ClickHouse, RDS, EventBridge), and checks how far each table was synced in the last run."
next_step: 3
- id: 3
event:
id: "CRMSyncStarted"
version: "1.0.0"
title: "Publish CRMSyncStarted"
summary: "Sends an event announcing that a new sync run has started. Includes the total table count and a unique run ID that links all events in this run together."
next_step: 4
- id: 4
externalSystem:
name: "Actian CRM365 (JDBC source)"
summary: "The source CRM database, connected over JDBC."
title: "Extract Changed Rows"
summary: "For each table, reads only the rows that changed since the last successful sync. Reads them in batches of 2,000 rows at a time."
next_step: 5
- id: 5
service:
id: "DataIngestionService"
version: "1.0.0"
title: "Transform and Hash PII"
summary: "Applies business rules (for example, mapping sales stages, calculating weighted values), fixes date and boolean formats, and creates a hashed lookup entry for any personal data (PII) in DynamoDB. "
next_steps:
- id: 6
label: "bronze layer (29)"
- id: 7
label: "gold layer (16)"
- id: 8
label: "operational (8 source tables)"
- id: 6
externalSystem:
name: "Amazon S3 (Bronze Bucket)"
summary: "A permanent archive of every table. If the analytics layer ever needs to be rebuilt, this is the source."
title: "Land Bronze Layer"
summary: "All 29 tables are saved here first. This is always the first write. Once the save is complete, the watermark is updated so these rows are not saved again."
next_steps:
- id: 7
label: "backfill replay"
- id: 9
- id: 7
externalSystem:
name: "ClickHouse (via S3 Gold Bucket + S3Queue)"
summary: "The analytics database. Data files are placed in a gold S3 bucket, and ClickHouse automatically picks them up and loads them into the analytics tables."
title: "Stage Gold Layer and Load ClickHouse"
summary: "16 tables are written to the gold bucket for ClickHouse. Before writing, the pipeline removes any rows that already exist in ClickHouse to avoid duplicates. It writes in batches of 10,000 rows (or 5,000 for very wide tables). After writing, it waits up to 90 seconds to confirm that ClickHouse has loaded all the rows."
next_step: 9
- id: 8
externalSystem:
name: "Amazon RDS (PostgreSQL)"
summary: "The database that the RIO application reads from directly."
title: "Load RDS Upsert"
summary: "8 source tables are loaded into 5 Postgres tables (person, tenant_role, opportunity, account_team, deal_team). New rows are inserted and existing rows are updated. Some source tables write to more than one target table, which is why 8 sources produce 5 targets."
next_steps:
- id: 9
- id: 10
label: "manager changed"
- id: 9
event:
id: "ETLBatchCompleted"
version: "1.0.0"
title: "Publish ETLBatchCompleted"
summary: "After each table is processed, a success or failure event is published. If one table fails, the pipeline moves on to the next table instead of stopping."
next_step: 11
- id: 10
event:
id: "HierarchyUpdatedByIngestion"
version: "1.0.0"
title: "Publish HierarchyUpdatedByIngestion"
summary: "Only happens for the system users table when a manager change is detected. Publishes events in batches of 1,000 to trigger a recalculation of the reporting hierarchy."
- id: 11
event:
id: "CRMSyncCompleted"
version: "1.0.0"
title: "Publish CRMSyncCompleted"
summary: "Sent after all tables have been processed. If any table failed during the run, a CRMSyncFailed event is sent instead, and the task exits with an error."
---
import Footer from '@catalog/components/footer.astro';
# RIO Ingestion Service - CRM Data Sync Pipeline
## What This Pipeline Does
This flow documents how CRM data is continuously copied into RIO. The CRM (**Actian CRM365**) is
the system of record, and the RIO application cannot query it directly. So on a schedule, this pipeline
pulls whatever changed since last time and fans it out to three purpose-built stores:
- **S3 (bronze layer)** — A permanent archive of every table. Think of it as a backup that can be used to rebuild the other stores if needed.
- **ClickHouse (gold layer)** — The analytics database that powers dashboards and the Cube semantic layer.
- **RDS PostgreSQL** - The transactional store the RIO application reads.
Each destination keeps track of how far it has synced. If one destination
fails, the others keep working. The next run will automatically retry only what the failed
destination missed.
## How This Flow Starts
This pipeline is **not triggered by events**. It is started by a timer (AWS EventBridge Scheduler).
| Environment | How Often |
|---|---|
| **Production** | Every hour |
| **Non-production** | Every 6 hours |
There is also a second way the pipeline can start: when someone uploads a product master file to S3.
In that case, the pipeline runs in a special "Product Master" mode (see Run Modes below).
## Step-by-Step Walk-Through
| Step | What Happens | Key Detail |
|------|---|---|
| 1 | **Timer triggers the pipeline** | Runs every hour (prod) or every 6 hours (non-prod) |
| 2 | **Service starts up and checks watermarks** | Connects to all required services and checks how far each table was synced last time |
| 3 | **CRMSyncStarted event is published** | Announces the start of the run with a unique run ID that links all events together |
| 4 | **Changed rows are extracted from the CRM** | Only rows that changed since the last sync are read, in batches of 2,000 |
| 5 | **Data is transformed and PII is hashed** | Business rules are applied, dates and booleans are standardized, and personal data is hashed into a DynamoDB lookup table|
| 6 | **Data is saved to S3 (bronze layer)** | All 29 tables are archived here first. This is always the first write |
| 7 | **Data is loaded into ClickHouse (gold layer)** | 16 tables are written to ClickHouse for analytics. Duplicates are removed before writing. The pipeline waits up to 90 seconds to confirm the load |
| 8 | **Data is loaded into RDS PostgreSQL** | 8 source tables are loaded into 5 Postgres tables. New rows are inserted, existing rows are updated |
| 9 | **ETLBatchCompleted event is published** | One event per table. If a table failed, an ETLBatchFailed event is sent instead, and the pipeline moves on |
| 10 | **HierarchyUpdatedByIngestion event is published** | Only for the system users table, and only when a manager change is detected |
| 11 | **CRMSyncCompleted event is published** | Sent after all tables are done. If any table failed, CRMSyncFailed is sent instead |
Steps 4 through 10 repeat for each of the 29 tables, one at a time.
## Where Each Table Lands
The pipeline processes **29 CRM tables**. All 29 are archived to S3 (bronze). On top of that, 16 go to ClickHouse and 8 go to RDS. 5 tables go to S3 only.
- **S3 (bronze archive)** — All 29 tables. Permanent archive and rebuild source.
- **ClickHouse (analytics)** — 16 tables covering accounts, campaigns, contracts, products, territories, leads, opportunities, quotes, sales orders, entitlements, and BPF history. These power dashboards and the Cube semantic layer.
- **RDS PostgreSQL (application)** — 8 source tables loaded into 5 target tables: person, tenant_role, opportunity, account_team, and deal_team. Some source tables write to more than one target, which is why 8 sources produce 5 targets.
- **S3 only** — 5 tables (customer addresses, beta program participants, pipeline stages, software programs, and software program products). Archived for reference but not loaded into any database.
## Run Modes
The pipeline can run in three modes:
| Mode | What It Does |
|---|---|
| **Incremental** (default) | Only syncs rows that changed since the last run. This is the normal mode used by the hourly schedule |
| **Initial Load** | Syncs all rows from scratch, without checking what was already synced. Used for first-time setup or full reloads |
| **Product Master** | Completely different from the other two. Skips the normal 29-table sync entirely. Instead, it reads an uploaded product master file from S3, compares it to what is already in ClickHouse, and only loads the changes. No CRM connection is used and no audit events are published |
---
id: UserAuthentication
name: User Authentication & Authorization
version: 2.0.0
summary: "Every request's journey from Cognito login through the API Gateway authorizer, the VPC Link, and each service's own identity and permission checks."
owners:
- revenue-intelligence
steps:
- id: 1
actor:
name: "RIO User"
summary: "A sales rep, manager or admin opening the RIO web app."
title: "Open the app"
summary: "The user lands on a RIO page. If there is no valid session, LoginPage automatically calls signinRedirect() unless the user has just deliberately logged out."
next_step: 2
- id: 2
service:
id: "RioWebApp"
version: "1.0.0"
title: "Redirect to Cognito"
summary: "react-oidc-context redirects to the Cognito hosted UI using the authorization-code flow, scope 'email openid profile'."
next_step: 3
- id: 3
externalSystem:
name: "AWS Cognito Hosted UI"
summary: "A pre-existing user pool. It is NOT created by any template in these repos - only referenced by id."
title: "User signs in"
summary: "Cognito authenticates the user and redirects back to /auth/callback with an authorization code, which oidc-client-ts exchanges for tokens."
next_step: 4
- id: 4
custom:
title: "Token stored in browser"
icon: "KeyIcon"
type: "Browser storage"
color: "orange"
summary: "The ID token - not the access token - is what RIO uses for API calls."
properties:
Token used: "id_token"
Storage: "window.localStorage"
Silent renew: "automaticSilentRenew: true"
Configured at: "src/features/auth/authConfig.ts:9-22"
title: "Session established"
summary: "The ID token is kept in localStorage via WebStorageStateStore and refreshed silently in the background."
next_step: 5
- id: 5
custom:
title: "httpClient attaches credentials"
icon: "PaperAirplaneIcon"
type: "HTTP client"
color: "blue"
summary: "Every outbound call carries two headers. Both matter later."
properties:
Header 1: "Authorization: Bearer {id_token}"
Header 2: "X-User-Id: {person_internal_id}"
Source: "src/lib/httpClient.ts:82-88"
title: "Request sent"
summary: "A single base URL (VITE_API_BASE_URL) points at the API Gateway. There are no per-service hostnames in the frontend."
next_step: 6
- id: 6
custom:
title: "API Gateway"
icon: "GlobeAltIcon"
type: "AWS API Gateway (REST)"
color: "purple"
summary: "Regional REST API. The authorizer binding lives in the OpenAPI document, not in the SAM template."
properties:
Resource: "AWS::Serverless::Api - infrastructure/template.yaml:195-227"
Endpoint type: "REGIONAL"
Authorizer type: "TOKEN"
Identity source: "method.request.header.Authorization"
Result cache: "authorizerResultTtlInSeconds: 300"
CORS origin: "'*'"
title: "Perimeter check"
summary: "60 operations require the authorizer. Three are deliberately public: GET /health, GET /activity/health, and the OAuth callback /activity/integrations/{provider}/callback."
next_steps:
- id: 7
label: "token present"
- id: 12
label: "no / malformed token"
- id: 7
custom:
title: "Lambda Authorizer"
icon: "ShieldCheckIcon"
type: "AWS Lambda"
color: "green"
summary: "Verifies the token signature against the Cognito JWKS."
properties:
Function: "${DeployPrefix}-authorizer"
Runtime: "python3.11 / arm64 / 256 MB / 30 s"
Code: "lambda/authorizer/index.py"
JWKS cache: "3600 s, module-level, survives warm starts"
Algorithm: "RS256"
title: "Verify the JWT"
summary: "Checks issuer, expiry and signature via python-jose, then manually checks the audience: 'aud' for ID tokens, 'client_id' for access tokens. Also requires token_use to be 'id' or 'access'."
next_steps:
- id: 8
label: "valid"
- id: 12
label: "any exception"
- id: 8
custom:
title: "Allow policy + context"
icon: "DocumentCheckIcon"
type: "IAM policy document"
color: "green"
summary: "The authorizer only ever returns Allow. There is no Deny branch - failures raise instead."
properties:
Effect: "Allow (always)"
Resource: "{apiId}/{stage}/* - the whole API, not the called method"
Context: "sub, email, name, token_use"
principalId: "claims.sub"
title: "Authorize"
summary: "Returns an IAM Allow policy and injects the caller's identity into the request context for downstream use."
next_step: 9
- id: 9
custom:
title: "VPC Link → ALB"
icon: "ArrowsRightLeftIcon"
type: "Private network path"
color: "gray"
summary: "Every route is an http_proxy integration over a VPC Link to an internal Application Load Balancer."
properties:
Integration: "type: http_proxy, connectionType: VPC_LINK"
Connection: "${stageVariables.VpcLinkId}"
Routing: "ALB host-header rules per service"
Target type: "ECS Fargate tasks (ip targets, port 8000)"
title: "Route to the owning service"
summary: "The ALB picks the backend from the host header. The VPC Link, ALB and listener are pre-existing infrastructure, referenced by ARN rather than created."
next_step: 10
- id: 10
service:
id: "IdentityHierarchyService"
version: "1.0.0"
title: "Service receives the request"
summary: "Shown here as the Identity Service, but every FastAPI service applies the same pattern - a global validate_user_token_match dependency."
next_step: 11
- id: 11
custom:
title: "Local identity binding"
icon: "FingerPrintIcon"
type: "FastAPI dependency"
color: "yellow"
summary: "The service re-decodes the token WITHOUT verifying its signature, then binds it to a real person row."
properties:
Dependency: "validate_user_token_match - api/core/auth.py:56-188"
Signature check: "NONE - verify_signature: False (auth.py:28)"
Binds: "token email vs dim_person email columns, WHERE person_internal_id = X-User-Id"
Filters: "is_active = TRUE AND person_type = 'Internal Rep'"
Cache: "TTLCache, 300 s, 1000 entries"
title: "Who are you, really?"
summary: "Confirms the X-User-Id header belongs to the person the token was issued for. Mismatch returns 403. This is what stops a valid token being used with someone else's user id."
next_step: 13
- id: 12
custom:
title: "401 Unauthorized"
icon: "XCircleIcon"
type: "Gateway response"
color: "red"
summary: "Any failure in the authorizer becomes a 401 - there is no distinct failure mode."
properties:
Mapped responses: "UNAUTHORIZED→401, EXPIRED_TOKEN→401, ACCESS_DENIED→403"
Frontend behaviour: "httpClient redirects to /auth/login"
Catch clause: "except (JWTError, Exception) - swallows everything"
title: "Rejected"
summary: "The browser is sent back to the login page and the session is cleared."
- id: 13
custom:
title: "Permission check"
icon: "LockClosedIcon"
type: "RBAC lookup"
color: "yellow"
summary: "Identity is not authority. A second lookup decides what this person may actually do."
properties:
Source table: "dim_tenant_role"
Join: "person.tenant_id = role.tenant_id AND person.standard_role = role.role_code"
Flags: "can_manage_team, can_view_audit, can_manage_quota, can_submit_forecast, can_adjust_forecast, can_view_subtree, can_view_region_data, …"
Cache: "TTLCache, 1800 s, 5000 entries"
title: "Are you allowed?"
summary: "Each endpoint requires a specific can_* flag - can_view_audit for the audit trail, can_manage_quota for quota writes. Failure returns 403."
---
# User Authentication & Authorization
This flow documents the security chain every request passes through before any business logic runs.
> **This page was rewritten in version 2.0.0.** The previous version described an "Okta SSO →
> Cognito" federation step. That step is **not evidenced anywhere in the codebase or
> infrastructure** — see [What changed](#what-changed-in-this-version) at the end.
## The short version
```
Browser → Cognito hosted UI → ID token
→ API Gateway (Lambda authorizer, 5-min cache)
→ VPC Link → internal ALB → ECS service
→ FastAPI dependency binds token to a person row
→ RBAC flags decide what that person may do
```
There are **three independent checks**, and they do different jobs:
| Layer | Question it answers | Where |
|---|---|---|
| Lambda authorizer | Is this token real and unexpired? | API Gateway, before the request enters the VPC |
| `validate_user_token_match` | Does this token belong to the user id being claimed? | Inside each service |
| `can_*` role flags | Is this person allowed to do this? | Inside each service, per endpoint |
Passing the gateway gets you into the network. It does not get you data.
## Cognito is not managed as code
Worth knowing before you go looking for it: **no template in any of the 13 repositories creates a
Cognito user pool.** A search for `AWS::Cognito` across the whole tree returns nothing.
Cognito is referenced by id only, as CloudFormation parameters
(`rio-app-layer/infrastructure/template.yaml:130-141`) fed from `samconfig.tmpl`:
| Environment | User pool | App client |
|---|---|---|
| dev | `us-east-1_rlGZpoMS1` | `3ou98gb8ea86secv9qi2jlugr7` |
| qa | `us-east-1_KPrkagVLm` | `18pl3p6cu4hrrc20dpvfhbngci` |
| prod | `us-east-1_wfRWhZlBM` | `6ptetb7kmsainvcin2j6ucpe7u` |
The app client configuration — allowed callback URLs, OAuth flows, and whether any external identity
provider is attached — lives outside these repositories. A comment in
`rio-ui/src/features/auth/authConfig.ts:7` confirms it is managed out of band.
**Practical consequence:** changing a redirect URI requires a console or IaC change somewhere else.
Deploying these repos will not do it.
## Layer 1 — Browser login
The frontend uses `react-oidc-context` + `oidc-client-ts` against the Cognito hosted UI.
| Setting | Value | Where |
|---|---|---|
| Flow | Authorization code | `VITE_COGNITO_RESPONSE_TYPE=code` |
| Scope | `email openid profile` | `.env.example:8` |
| Token storage | `window.localStorage` | `authConfig.ts:16` |
| Silent renew | enabled | `authConfig.ts:17` |
| Token sent to APIs | **`id_token`** | `AuthContext.tsx:238-239` |
Two details worth flagging:
- **RIO sends the ID token, not the access token.** The authorizer accepts both, but the frontend
always sends `id_token`. If you are testing with an access token you are exercising a path the
product does not use.
- **PKCE is not explicitly configured.** `oidc-client-ts` enables it by default for the code flow, so
it is almost certainly active — but nothing in the repo sets it, so nothing in the repo protects it
from a future default change.
The app also supports being embedded in a host shell, in which case tokens arrive as props instead
(`AuthProvider.tsx:19-48`). If neither shell props nor Cognito config are present, **authentication
is silently disabled** with only a `console.warn`.
## Layer 2 — The API Gateway authorizer
A TOKEN-type authorizer, bound in the OpenAPI document rather than the SAM template
(`rio-app-layer/openapi.yaml:4470-4481`).
It fetches the Cognito JWKS, caches it for an hour, and verifies:
1. Signature, RS256, via `python-jose`
2. Issuer matches the configured pool
3. Expiry
4. `token_use` is `id` or `access`
5. Audience — `aud` for ID tokens, `client_id` for access tokens (checked manually, because
`verify_aud` is switched off in the decode call)
### Four behaviours that surprise people
**It never returns Deny.** The function only ever builds an `Allow` policy. Every failure path
`raise`s instead, which API Gateway maps to a 401. So there is no way to distinguish "expired token"
from "JWKS endpoint unreachable" from the outside.
**It catches everything.** The handler's `except (JWTError, Exception)` swallows all exceptions —
including network errors reaching Cognito. A JWKS outage looks exactly like a bad token.
**The Allow policy covers the whole API, not the called route.** The resource ARN is widened to
`{apiId}/{stage}/*` (`index.py:123-126`). Combined with the 300-second result cache, one successful
authorization grants every route for five minutes. Per-route authorization is therefore entirely the
responsibility of the services, not the gateway.
**`token_use` in the context is hardcoded.** The context always reports `"id"` regardless of the real
token type (`index.py:175-180`), and `client_id` is not forwarded. The authorizer's own README claims
otherwise — the code is authoritative.
### Public routes
Three operations skip the authorizer entirely:
| Route | Why |
|---|---|
| `GET /health` | Load balancer health probe |
| `GET /activity/health` | Same, for the activity service |
| `GET /activity/integrations/{provider}/callback` | Google's OAuth redirect — the caller is Google, which has no RIO token |
## Layer 3 — Identity binding inside the service
Every FastAPI service registers `validate_user_token_match` as a **global dependency**
(`rio-identity-service/api/main.py:62`).
**It does not re-verify the signature.** The decode call passes
`options={"verify_signature": False}` (`api/core/auth.py:28`). This is a deliberate trust boundary:
the service assumes the request could only have reached it through the gateway, which already
verified the token.
> **This assumption is load-bearing.** It holds because the ALB is internal and the tasks run with
> `AssignPublicIp: DISABLED`. Anything that could reach an ECS task directly — a bastion, a
> misconfigured security group, another service in the VPC — would be able to present an unsigned
> JWT and be believed. Worth keeping in mind when changing network rules.
What it actually checks:
1. `Authorization` header exists and starts with `Bearer `.
2. The token decodes and contains an `email` claim.
3. If an `email` query parameter is present, it must match the token's email.
4. If `X-User-Id` is present, it must be a valid UUID **and** must resolve to a row in `dim_person`
whose email matches the token's, filtered to `is_active = TRUE AND person_type = 'Internal Rep'`.
Mismatch returns **403**. Results are cached for 300 seconds.
**A gap worth noting:** if *neither* `X-User-Id` nor `email` is supplied, the function returns
without binding any identity. Only "the token decodes and has an email" has been checked. Endpoints
that do not separately require `X-User-Id` are relying on the caller to supply it.
Health, docs, OpenAPI and any path ending in `/timezones` are skipped. The whole check can be
switched off with `AUTH_VALIDATION_ENABLED=false` — wired as an ECS environment variable in
`rio-commit-service`, but not set in `rio-identity-service`, which therefore uses the code default of
`True`.
## Layer 4 — Permissions
Identity is not authority. Endpoints requiring privilege perform a second lookup against
`dim_tenant_role`, joined on `person.tenant_id = role.tenant_id AND person.standard_role =
role.role_code` (`api/services/permissions_service.py:61-76`).
The flags include `can_manage_team`, `can_view_audit`, `can_manage_quota`, `can_submit_forecast`,
`can_adjust_forecast`, `can_view_subtree`, `can_view_region_data`, `can_view_dealdesk`, `can_modify`
and `is_revenue_owner`.
These same flags drive the
Opportunity Service's four-way
visibility rules — so the RBAC table decides both *whether* you may call an endpoint and *which rows*
it returns.
Cached for 1800 seconds, 5000 entries.
## What changed in this version
The 1.0.0 version of this page described this chain:
```
User → Okta SSO → Cognito (JWT) → API Gateway → Service Middleware
```
**The Okta step has been removed.** A search of all 13 repositories for `okta`, `saml`, `federat`,
`IdentityProvider`, `identity_provider` and `SupportedIdentityProviders` found:
- **Zero** hits in `rio-ui`, `rio-app-layer`, `rio-identity-service` or `rio-infra`.
- No `AWS::Cognito::UserPoolIdentityProvider` resource anywhere.
- No `identity_provider` query parameter on the login redirect — `signinRedirect()` is called bare,
with no `extraQueryParams` (`LoginPage.tsx:109`).
- The only `federation` hits in `rio-ui` are **Module Federation**, the micro-frontend bundler.
- The only "Okta" text in the entire tree was this catalog page, plus a commented-out block in
`eventcatalog.auth.js` for logging into *this documentation site*.
Because the Cognito pool is not managed as code here, an external IdP **may** be attached to it — that
is invisible from these repos. But it cannot be asserted as an infrastructure fact, and the previous
version stated it as one. What is evidenced is a direct Cognito hosted-UI login.
The rest of the rewrite adds the infrastructure layer that was missing: the API Gateway
configuration, the authorizer's caching and policy scope, the VPC Link and ALB path, and the RBAC
lookup that was previously not mentioned at all.