Domain events
Every event published from the outbox by a Constellation module. Each row links to the schema definition in source so an AI agent or reviewer can follow the type from the published event back to the publishing code.
The naming rule is event-naming (<module-namespace>.<entity>.<verb-past-tense> — for the current modules the namespace happens to coincide with the DB schema, but conceptually it's the module's published-event prefix). Events are append-only contracts — see events-append-only.
Notation in the tables below: field? means the field may be omitted (Zod .optional()); field | null means the field is always present but may be null (Zod .nullable()). The two are different on the wire.
How to use this index
- Looking up a published event by name? Find the row, follow the source link to the Zod schema for the exact payload shape.
- Adding a new event? Add it to the appropriate
*.events.tssource file — the schema there is authoritative — then update this index in the same PR. - Renaming or breaking an event? Don't. See
events-append-onlyfor the additive-vs-breaking rules.
Cross-app delivery semantics
Constellation deploys as separate Vercel projects (separate processes). The outbox dispatcher in each app only invokes handlers that are registered in that app's own process (via subscribe() in instrumentation.ts). Cross-app delivery is made durable through the events.subscriptions table.
Completion model
An outbox row is marked dispatched_at only when every row in events.subscriptions for that event type has a DELIVERED delivery entry for that outbox id. The completion check queries the durable table — not the in-memory registry — so a foreign subscriber that lives in another app's process is visible to the publishing app's dispatcher without any shared state.
An event type with no durable subscriptions anywhere completes on first poll (fire-and-forget behaviour, unchanged from pre-PLT-251).
Adding a subscriber in a different app
-
Register the handler in your app's
instrumentation.tsviasubscribe()from@constellation-platform/events. The handler name must be stable — it is stored inevents.subscriptionsand used as a completion key. -
Run a dispatcher cron in your app. Wire up
POST /api/cron/dispatch-events(protect withCRON_SECRET, use a dedicatedDISPATCHER_DATABASE_URLclient, and callregisterDurableSubscriptions(tx)beforedispatchBatch). Add acronsentry in your app'svercel.json—"* * * * *"matches the platform default. -
Seed the subscription row if the event type already has live publishers. Add a migration to your app's Prisma schema in the same PR:
INSERT INTO events.subscriptions (event_type, handler_name)VALUES ('some.event.type', 'your-handler-name')ON CONFLICT DO NOTHING;Events published before your dispatcher's first tick are vacuously completed and not retroactively re-delivered when the subscription row appears later. The seed migration ensures your row exists before the publishing code reaches production.
Stale-subscription caveat
If you remove a handler from code without deleting its events.subscriptions row, every future event of that type will wait indefinitely for a delivery that can never happen — no dispatcher will invoke the missing handler, so the row stays unsatisfied and the event is never marked dispatched.
Manual cleanup:
DELETE FROM events.subscriptions
WHERE event_type = 'some.event.type'
AND handler_name = 'your-handler-name';
Run this against the production database before (or immediately after) removing the handler from code.
Head-of-line note
A dispatcher re-scans events that it cannot complete (foreign subscription unsatisfied). At current volumes with 1-minute crons and a batch size of 100, this is acceptable. If a subscriber app's dispatcher is down long enough for more than 100 such events to accumulate, newer events queue behind them. A batch-skip optimisation is a planned follow-up if this becomes a problem in practice.
The pattern for publishing is always inside a transaction:
import { publish } from '@constellation-platform/events';
await tx.someTable.update({ ... });
await publish(tx, {
eventType: 'projects.task.completed',
payload: { taskId, projectId, completedBy },
meta: { tenantId, actorId, correlationId },
});
The outbox and the mutation share the transaction, so either both land or neither does.
Directory
Event namespace: directory.* (Directory's DB schema is identity — not the same as the event prefix). Source: apps/directory/src/server/events/directory.events.ts.
| Event | Payload (key fields) |
|---|---|
directory.tenant.created | tenantId, name, type |
directory.tenant.updated | tenantId, changes |
directory.organisation.created | organisationId, name, type |
directory.organisation.verified | organisationId, verifiedBy |
directory.organisation.verification.requested | organisationId, requestedBy, checks[] |
directory.organisation.verification.completed | organisationId, verifiedBy, completedChecks[] |
directory.organisation.verification.rejected | organisationId, rejectedBy, reason |
directory.user.created | userId, email, organisationId | null |
directory.user.suspended | userId, reason |
directory.role.assigned | userId, roleId, scopeType, scopeId | null |
directory.role.unassigned | userId, roleId, scopeType, scopeId | null |
directory.role.permission_changed | roleId, changes |
directory.credential.expiry.approaching | credentialId, userId, organisationId | null, credentialType, credentialName, expiryDate, daysUntilExpiry |
directory.credential.expiry.imminent | credentialId, userId, organisationId | null, credentialType, credentialName, expiryDate, daysUntilExpiry |
directory.credential.expired | credentialId, userId, organisationId | null, credentialType, credentialName, expiryDate |
directory.qualification.updated | organisationId, qualificationType, status |
directory.user_credential.updated | userId, credentialId, credentialType, status |
Catalog
Event namespace: catalog.* (the catalog module's published-event prefix; DB schema happens to share the same name). Source: apps/catalog/src/server/events/catalog.events.ts.
| Event | Payload (key fields) |
|---|---|
catalog.entry.created | entryId, name, category |
catalog.entry.updated | entryId, changes |
catalog.entry.deleted | entryId |
catalog.entry.status_changed | entryId, previousStatus, newStatus |
catalog.taxonomy.created | categoryId, code, name, parentPath |
catalog.taxonomy.updated | categoryId, changes |
catalog.taxonomy.deleted | categoryId, code |
catalog.taxonomy.moved | categoryId, code, oldParentPath, newParentPath |
catalog.shortlist.created | shortlistId, name |
catalog.shortlist.updated | shortlistId, changes |
catalog.shortlist.deleted | shortlistId |
catalog.shortlist.archived | shortlistId |
catalog.shortlist.entry_added | shortlistId, entryId |
catalog.shortlist.entry_removed | shortlistId, entryId |
Wiki
Event namespace: wiki.* (the wiki module's published-event prefix; DB schema happens to share the same name). Source: apps/wiki/src/server/events/wiki.events.ts.
Payloads never include the markdown body — they carry ids + minimal metadata. Subscribers that need the body call back through the wiki REST API. This keeps tenant + classification isolation intact across module boundaries.
| Event | Payload (key fields) |
|---|---|
wiki.space.created | spaceId, tenantId, name, slug — see wiki.events.ts |
wiki.space.deleted | spaceId, tenantId, deletedAt, slug?, pageCount? — see wiki.events.ts |
wiki.page.created | pageId, tenantId, slug, parentId | null, classification, ownerUserId | null |
wiki.page.updated | pageId, tenantId, revisionNum, changedFields[], classification |
wiki.page.published | pageId, tenantId, fromStatus, toStatus, revisionNum, classification |
wiki.page.archived | pageId, tenantId, revisionNum |
wiki.page.deleted | pageId, tenantId, deletedAt |
wiki.page.restored | pageId, tenantId, parentId | null, classification, restoredAt |
wiki.link.added | linkId, tenantId, sourceId, targetId, linkType |
wiki.link.removed | linkId, tenantId, sourceId, targetId, linkType |
wiki.attachment.uploaded | attachmentId, tenantId, pageId | null, mimeType, sizeBytes, classification |
wiki.space.deleted (PLT-192, widened additively in PLT-205) — emitted when a space is soft-deleted, by both the empty-space delete path (deleteSpace) and the PLT-205 decommission cascade (decommissionSpace — bulk teardown of an agent-populated space). slug? and pageCount? are PLT-205 additive-optional widenings — both current publishers include slug (and the decommission path includes pageCount, the number of live pages cascade-archived), but historical outbox rows carry only the original three fields, so consumers must not require them. Cross-module subscriber: PT's projects.on-wiki-space-deleted handler removes the dead space id from every knowledgeBaseSpaceIds[] array on initiatives and projects (PT-374), idempotently. Contract schema: packages/contracts/src/events/wiki.ts (WikiEventSchemas['wiki.space.deleted']).
Project Tracker — projects & programmes
Event namespace: projects.* (PT's DB schema is also called projects — the two happen to coincide here). Source: packages/contracts/src/events/projects.ts (the cross-module contract; module re-exports via apps/project-tracker/src/server/events/projects.events.ts).
| Event | Payload (key fields) |
|---|---|
projects.project.status_changed | projectId, fromStatus, toStatus |
projects.project.exported | projectId, format, taskCount, stageCount |
projects.project.imported | projectId, format, tasksCreated, tasksUpdated, stagesCreated |
projects.programme.progress_updated | programmeId, progress (0–100) — legacy; emitted alongside the initiative event during the PLT-182 dual-emit window |
projects.initiative.progress_updated | initiativeId, progress (0–100) — successor to the programme event |
projects.stage.progressed | stageId, projectId, fromStatus, toStatus |
projects.gate.reviewed | gateId, stageId, decision (PASS / FAIL / WAIVE / HOLD), reviewerId |
Project Tracker — tasks
Source: packages/contracts/src/events/projects.ts (same contract source as the projects & programmes section).
| Event | Payload (key fields) |
|---|---|
projects.task.completed | taskId, projectId, completedBy |
projects.task.recurring_spawned | sourceTaskId, newTaskId, projectId, recurrenceIntervalDays |
Project Tracker — milestones
Source: packages/contracts/src/events/projects.ts.
| Event | Payload (key fields) |
|---|---|
projects.milestone.overdue | milestoneId, projectId, organisationId, title, dueDate, overdueAt, paymentAmount?, paymentCurrency? |
Project Tracker — issues
Source: packages/contracts/src/events/issues.ts.
| Event | Payload (key fields) |
|---|---|
projects.issue.created | issueId, projectId, severity, reportedBy |
projects.issue.status_changed | issueId, projectId, fromStatus, toStatus, changedBy |
projects.issue.assigned | issueId, projectId, assigneeId, assignedBy |
projects.issue.escalated | issueId, projectId, fromSeverity, toSeverity, escalatedBy |
projects.issue.resolved | issueId, projectId, resolvedBy, resolution (FIXED / WONT_FIX / DUPLICATE / DEFERRED) |
projects.issue.comment_added | issueId, commentId, authorId, isInternal |
projects.issue.sla_breached | issueId, breachType (FIRST_RESPONSE / RESOLUTION), targetDeadline, actualAt |
projects.issue.portal_ticket_created | issueId, projectId, severity, reportedBy, source: 'PORTAL', categoryId? |
projects.issue.promoted | issueId, taskId, taskKey | null, projectId, mode (create / link), sourceIssueClosed, actorId, tenantId |
projects.issue.follower_changed | issueId, userId, action (followed / unfollowed), tenantId, actorId |
projects.issue.moved | issueId, fromProjectId, toProjectId, fromIssueKey, toIssueKey, actorId, tenantId |
projects.issue.forwarded | issueId, projectId, fromUserId, toUserId, previousAssigneeId | null, note | null, tenantId, actorId |
projects.macro.created | macroId, title, isInternal, tenantId, actorId |
projects.macro.updated | macroId, changes (title? / isInternal? / bodyChanged?), tenantId, actorId |
projects.macro.deleted | macroId, tenantId, actorId |
Project Tracker — risks
Source: packages/contracts/src/events/risks.ts (cross-module contract; module re-exports via apps/project-tracker/src/server/events/risks.events.ts).
| Event | Payload (key fields) |
|---|---|
projects.risk.created | riskId, projectId, likelihood, impact, raisedBy |
projects.risk.status_changed | riskId, projectId, fromStatus, toStatus, changedBy |
projects.risk.mitigated | riskId, projectId, mitigatedBy, mitigationNotes |
projects.risk.accepted | riskId, projectId, acceptedBy, justification |
projects.risk.reassessed | riskId, projectId, oldLikelihood, newLikelihood, oldImpact, newImpact, assessedBy |
Project Tracker — deliverables
Source: packages/contracts/src/events/deliverables.ts.
| Event | Payload (key fields) |
|---|---|
projects.deliverable.created | deliverableId, projectId, authorId |
projects.deliverable.submitted | deliverableId, projectId, authorId |
projects.deliverable.review_started | deliverableId, projectId, reviewerId, startedBy |
projects.deliverable.accepted | deliverableId, projectId, reviewerId |
projects.deliverable.rejected | deliverableId, projectId, reviewerId, notes |
projects.deliverable.updated | deliverableId, projectId, updatedBy, fields[] |
projects.deliverable.deleted | deliverableId, projectId, deletedBy |
projects.deliverable.status_changed | deliverableId, projectId, fromStatus, toStatus, changedBy |
Project Tracker — feedback
| Event | Payload (key fields) |
|---|---|
projects.feedback.status_changed | feedbackId, fromStatus, toStatus, githubIssueId | null |
projects.feedback.promoted | feedbackId, issueId, issueKey, actorId, tenantId |
Platform — audit
Published by @constellation-platform/audit whenever auditCritical() is called inside a transaction. Source: packages/platform/audit/src/audit-critical.ts.
| Event | Payload (key fields) |
|---|---|
audit.entry.created | tenantId, actorId, actorType, action, resourceType, resourceId, module, correlationId, changes, classification?, ipAddress? |
This event drives downstream SIEM / compliance pipelines — see the audit-critical rule for which mutations must publish it.
Platform — coordinator
Event namespace: coordinator.* (the hosted reasoning service; DB schema is also coordinator). Cross-module contract: packages/contracts/src/events/coordinator.ts.
| Event | Publisher | Payload (key fields) |
|---|---|---|
coordinator.token_usage.recorded | @constellation-platform/coordinator — token-usage.ts | tenantId, organisationId | null, userId, initiativeId | null, agentId | null, sessionId, turnId | null, day, stepIndex, providerId, modelId, inputTokens, outputTokens, cachedInputTokens, reasoningTokens, finishReason | null, loopAbortReason | null |
coordinator.consult.synthesized | @constellation-platform/coordinator — consult.ts publishConsultSynthesized | consultId, tenantId, initiativeId, sessionId, userId, questionExcerpt (≤500 chars), answer (full text), citations (≤20 of { slug, title?, reason? }), model, createdAt, kbSpaceSlug? (optional, PLT-252), durability? (optional, PLT-291) |
coordinator.token_usage.recorded — emitted by recordTokenUsage() (PLT-177) on a per-step token-usage write so cost dashboards / per-tenant metering react without polling. Published via events.publish() in the same transaction as the two table writes (transactional-outbox invariant) — a committed cost row always has its event. The whole attempt is best-effort and never-throwing (R12): on any failure the transaction rolls back and the turn continues.
coordinator.consult.synthesized (PLT-199) — emitted INSIDE the same transaction as the coordinator.consults INSERT for every successful, initiative-scoped consult on an UNCLASSIFIED initiative (persistConsult → publishConsultSynthesized). The payload carries the full answer text + citations so the subscriber needs no read-back call to PT. Emission is gated: tenant-wide consults, error consults, and consults on initiatives classified above UNCLASSIFIED do not emit this event (classification-leak guard — the KB space and the outbox payload are both UNCLASSIFIED surfaces). Subscriber: wiki wiki.file-back-consult handler — files the consult back as an is_agent_owned synthesis page in the tenant's knowledge-base wiki space, adding derived_from provenance links to any cited pages. The optional kbSpaceSlug field (PLT-252, additive) carries the slug of the KB space the coordinator's read path targeted (host config — PT threads COORDINATOR_KB_SPACE_SLUG ?? 'knowledge-base'); the wiki file-back writes into that same space, defaulting to knowledge-base when the field is absent (pre-PLT-252 events). The optional durability field (PLT-291, additive — 'durable' | 'time_bound') carries the model's file-back verdict: ephemeral consults (planning / status / "what next" / probe) never emit this event at all (the emit gate drops them, so they are never frozen as canonical KB), and time_bound pages are stamped with an expires_at the reader excludes and the daily curator reaper archives. Absent → the file-back treats the page as durable (no expiry). Schema: packages/contracts/src/events/coordinator.ts (CoordinatorEventSchemas['coordinator.consult.synthesized']).
Reserved coordinator audit-action names
These are emitted as audit-log entries via auditCritical (per audit-logging), not as outbox domain events, so they are not published via events.publish(). The changes payload shape IS contracted in packages/contracts/src/events/coordinator.ts (CoordinatorPendingActionAuditSchemas) so consumers can validate it. The canonical names are reserved here so future cross-module subscribers cannot collide on the namespace. To react, subscribe to audit.entry.created and filter on payload.action.startsWith('coordinator.pending_action.'). Source: prepare-mutation.ts + pending-actions.ts.
Audit action name | Emitted when |
|---|---|
coordinator.consult.created | A coordinator consult row is written (PLT-170, AC 4). |
coordinator.pending_action.pending | prepareMutation() stages a new coordinator.pending_actions row (PLT-179). |
coordinator.pending_action.executing | Row atomically claimed (pending → executing) before the side effect — the exactly-once gate blocking double-submit + non-author execution. |
coordinator.pending_action.executed | The claimed side effect succeeded; carries executedUrl. |
coordinator.pending_action.cancelled | A pending action is cancelled before execution. |
coordinator.pending_action.expired | The TTL sweep (coordinator.expire_pending_actions()) marks a pending row past expires_at. |
coordinator.pending_action.failed | The side effect failed; the row is reverted executing → pending (retryable) and records error. |