Design for a bidirectional NetDocuments integration hosted in supernova-api. Kafka is the only channel back to the Centerbase Platform — supernova-api does not call CB Core REST endpoints from event handlers, and CB Core does not call supernova-api REST endpoints for sync work.
| CB entity | NetDocs entity | Field-level mapping | Direction |
|---|---|---|---|
| Client | Profile (Client) | cb.client.id → nd.client.code; cb.client.name → nd.client.description |
CB → ND |
| Matter | Workspace | cb.matter.id → nd.workspace.matter; cb.matter.name → nd.workspace.description; cb.matter.status → nd.workspace.status |
CB → ND |
| Practice area | Profile attribute | cb.matter.practiceArea → nd.attr.PracticeArea (custom cabinet attribute) |
CB → ND |
| Responsible atty | Profile attribute | cb.matter.responsibleAttorney.email → nd.attr.ResponsibleAttorney (resolved to NetDocs user) |
CB → ND |
| Document | Document | cb.document.id ↔ nd.document.envId (stored in nd_id_map); cb.document.name → nd.document.name; mime/version preserved |
two-way |
| Document version | Document version | NetDocs is source of truth for version chain after upload; CB pulls latest via webhook → sync | ND → CB |
| User | NetDocs user | Match on cb.user.email ↔ nd.user.email at token-exchange time; no automated provisioning |
lookup |
| Tags / labels | Cabinet keywords | cb.matter.tags[] → nd.workspace.keywords[] |
CB → ND |
The nd_id_map table is the system of record for cross-system identity ((tenant_id, cb_entity_type, cb_entity_id, nd_envelope_id, nd_url)). All other mappings are derivable from current state on each side.
Three independent paths, each driven by the originating side. There is no synchronous round-trip that crosses the CB ↔ ND boundary in both directions on a single request.
Centerbase UI → supernova-api REST → NetDocs REST. Used for actions the user is waiting on: open a document, upload a draft, run an ndMax search. Response flows back the same path. No Kafka involvement.
CB Core publishes domain events (matter.created, matter.updated, client.updated, doc.uploaded) to centerbase.events. NetDocsSyncService (an IEventHandler in the existing Kafka consumer) projects them into NetDocs workspace/profile/document calls. Failures retried with backoff; permanent failures land in a DLQ.
NetDocs webhooks → NetDocsWebhookController → persisted to nd_webhook_events → normalized event published on netdocs.events. CB Core has its own consumer on netdocs.events. This is the only way ND-originated state changes reach the Centerbase Platform. Supernova-api never calls back into CB Core directly.
| Direction | Trigger | Transport | Latency target | Idempotency key |
|---|---|---|---|---|
| CB → ND | User action in CB UI | REST (synchronous) | <2s p95 | Request-scoped; not retried server-side |
| CB → ND | CB Core domain event | Kafka centerbase.events |
<30s p95 | (tenant_id, cb_event_id) |
| ND → CB | NetDocs webhook | Kafka netdocs.events |
<10s p95 | (tenant_id, nd_event_id) |
NetDocs sends signed callbacks for the subscribed events. Receiver pattern mirrors the existing Kafka consumer:
| NetDocs event | What we do |
|---|---|
document.created |
Look up workspace in nd_id_map; emit netdocs.document.created on Kafka with CB matter id |
document.updated |
Pull new version metadata; emit netdocs.document.updated; CB Core decides whether to re-index/notify |
document.checked_in |
Clear "checked-out by" badge in CB UI via downstream consumer |
workspace.attribute_changed |
Detect drift vs CB matter; if matter is system of record, enqueue corrective push back to NetDocs |
user.permissions_changed |
Invalidate cached NetDocs ACL for that user |
Webhook controller responsibilities:
X-ND-Signature HMAC against per-tenant shared secret (stored in nd_tokens).(tenant_id, nd_event_id) against nd_webhook_events. 24h TTL in Redis as fast-path.nd_webhook_events for replay.netdocs.events — CB Core's consumer does the heavy work out of band so the webhook returns 200 within NetDocs' retry window (usually <5s).Kafka contract for netdocs.events:
{
"schemaVersion": 1,
"eventType": "netdocs.document.updated",
"tenantId": "<cb_tenant>",
"occurredAt": "2026-05-14T13:22:01Z",
"ndEventId": "<nd_event_id>",
"subject": {
"ndEnvelopeId": "...",
"ndWorkspaceId": "...",
"cbMatterId": "...",
"cbDocumentId": "..."
},
"data": { /* event-specific */ }
}
CB-side identifiers (cbMatterId, cbDocumentId) are resolved via nd_id_map before publishing — CB Core consumers never need to call back into supernova-api to translate IDs.
POST /netdocs/authorization returns NetDocs auth URI scoped to the tenant.POST /netdocs/oauth/callback with code.NetDocsAuthController exchanges code for access_token + refresh_token; persists to nd_tokens keyed by (tenant_id, cb_user_id).NetDocsTokenProvider is the single read path: Redis cache (60s TTL) → DB → refresh-and-store if expired. Refresh failures throw NetDocsTokenInvalidException (mirrors how GraphServiceTokenProvider handles AAD).user.access_revoked clears both stores.Postgres, owned by supernova-api, snake_case via EF Core's UseSnakeCaseNamingConvention() (matches existing convention from Supernova.Infrastructure/DependencyInjection.cs:69). All tables are tenant-scoped — tenant_id is the FK to the existing tenants table from the single-schema migration.
nd_tokensOAuth credentials and per-tenant webhook signing secret.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
|
tenant_id |
bigint FK |
tenants(id). Tenant-scoped. |
cb_user_id |
text nullable |
Auth0 user id. NULL when token_kind = 'app'. |
token_kind |
text |
user for delegated OAuth, app for tenant-wide service account. |
access_token_encrypted |
text |
AES-encrypted via EncryptionHelper (same pattern as Auth0 secrets). |
refresh_token_encrypted |
text nullable |
NULL for app kind. |
access_token_expires_at |
timestamptz |
Used to pre-emptively refresh. |
scopes |
text |
Space-delimited granted scopes. |
webhook_secret_encrypted |
text nullable |
Shared HMAC secret for verifying inbound webhooks. Tenant-wide. |
created_at / updated_at |
timestamptz |
Standard audit columns. |
Indexes:
UNIQUE (tenant_id, cb_user_id, token_kind) — partial index where cb_user_id IS NOT NULL for user kind; separate unique partial for app (where cb_user_id IS NULL).INDEX (access_token_expires_at) — for the refresh sweeper.nd_id_mapSystem of record for cross-system identity. Every Kafka event publish goes through this table to resolve CB-side IDs before fanning out.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
|
tenant_id |
bigint FK |
|
cb_entity_type |
text |
matter, client, document. |
cb_entity_id |
text |
Stringified CB id (matters use uuid, documents use long). |
nd_entity_type |
text |
workspace, client, document. |
nd_envelope_id |
text nullable |
Set for documents; NetDocs document envelope id. |
nd_workspace_id |
text nullable |
Set for matters and workspace-scoped documents. |
nd_url |
text |
Stable deep link for UI. |
nd_last_seen_at |
timestamptz |
Touched by every webhook + sync confirmation; drives drift checks. |
Indexes:
UNIQUE (tenant_id, cb_entity_type, cb_entity_id)UNIQUE (tenant_id, nd_envelope_id) WHERE nd_envelope_id IS NOT NULLUNIQUE (tenant_id, nd_entity_type, nd_workspace_id) WHERE nd_entity_type = 'workspace'INDEX (tenant_id, nd_last_seen_at) — for drift / orphan reports.nd_webhook_eventsAppend-only audit + replay store for everything NetDocs pushes us.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
|
tenant_id |
bigint FK |
|
nd_event_id |
text |
Idempotency key (NetDocs guarantees uniqueness per event). |
event_type |
text |
document.created, document.updated, etc. |
raw_payload |
jsonb |
Full body for replay/debug. |
signature |
text |
The X-ND-Signature we verified, kept for audit. |
status |
text |
received → published (Kafka ack'd) → optional rejected/duplicate. |
kafka_offset |
text nullable |
Set when published, lets us tie audit rows to consumer offsets. |
error_message |
text nullable |
Populated on rejected. |
attempt_count |
smallint |
Increments on retry. |
received_at |
timestamptz |
|
processed_at |
timestamptz nullable |
Set on terminal status transition. |
Indexes:
UNIQUE (tenant_id, nd_event_id) — enforces idempotency.INDEX (status, received_at) — for dashboards and replay queues.Retention: 90 days hot, then archived to cold storage by a scheduled cleanup job.
nd_subscriptionsTracks webhook subscriptions we own at NetDocs. Webhook subscriptions expire (typically every few days) so we need to renew them on a schedule.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
|
tenant_id |
bigint FK |
|
nd_subscription_id |
text |
Returned by NetDocs on creation. |
resource |
text |
What we subscribed to (e.g., documents, workspaces). |
expires_at |
timestamptz |
Renewal sweeper triggers ~1h before this. |
status |
text |
active, renewing, expired. |
created_at / updated_at |
timestamptz |
Indexes:
UNIQUE (tenant_id, nd_subscription_id)INDEX (expires_at) WHERE status = 'active' — drives the renewal sweeper.nd_sync_outboxCB → ND async sync (the Kafka centerbase.events consumer path) writes to this table inside the same DB transaction as the source event's offset commit, then a worker drains it. Lets us retry NetDocs failures without re-processing the Kafka message and without poisoning the consumer.
| Column | Type | Notes |
|---|---|---|
id |
bigserial PK |
|
tenant_id |
bigint FK |
|
cb_event_id |
text |
CB event id from centerbase.events. Idempotency key. |
operation |
text |
create_workspace, update_workspace, upload_document, etc. |
payload |
jsonb |
Normalized request body — derived from CB event, snapshot at enqueue time. |
status |
text |
pending → in_flight → succeeded/failed/dead. |
attempt_count |
smallint |
Capped at N (e.g., 8); rolls over to dead past that. |
last_error |
text nullable |
|
next_attempt_at |
timestamptz |
Exponential backoff base; worker picks WHERE status='pending' AND next_attempt_at <= now(). |
created_at |
timestamptz |
|
completed_at |
timestamptz nullable |
Indexes:
UNIQUE (tenant_id, cb_event_id) — idempotency.INDEX (status, next_attempt_at) — worker dispatch.INDEX (tenant_id, status) — per-tenant DLQ inspection.timestamptz, UTC.EncryptionHelper.EncryptWithAes / DecryptWithAes (same pattern as GraphServiceClientFactory:97).Supernova.Persistence/Migrations alongside existing migrations; numbered after the single-schema migration 17f5a959.