Skip to main content

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.ts source file — the schema there is authoritative — then update this index in the same PR.
  • Renaming or breaking an event? Don't. See events-append-only for 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

  1. Register the handler in your app's instrumentation.ts via subscribe() from @constellation-platform/events. The handler name must be stable — it is stored in events.subscriptions and used as a completion key.

  2. Run a dispatcher cron in your app. Wire up POST /api/cron/dispatch-events (protect with CRON_SECRET, use a dedicated DISPATCHER_DATABASE_URL client, and call registerDurableSubscriptions(tx) before dispatchBatch). Add a crons entry in your app's vercel.json"* * * * *" matches the platform default.

  3. 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.

EventPayload (key fields)
directory.tenant.createdtenantId, name, type
directory.tenant.updatedtenantId, changes
directory.organisation.createdorganisationId, name, type
directory.organisation.verifiedorganisationId, verifiedBy
directory.organisation.verification.requestedorganisationId, requestedBy, checks[]
directory.organisation.verification.completedorganisationId, verifiedBy, completedChecks[]
directory.organisation.verification.rejectedorganisationId, rejectedBy, reason
directory.user.createduserId, email, organisationId | null
directory.user.suspendeduserId, reason
directory.role.assigneduserId, roleId, scopeType, scopeId | null
directory.role.unassigneduserId, roleId, scopeType, scopeId | null
directory.role.permission_changedroleId, changes
directory.credential.expiry.approachingcredentialId, userId, organisationId | null, credentialType, credentialName, expiryDate, daysUntilExpiry
directory.credential.expiry.imminentcredentialId, userId, organisationId | null, credentialType, credentialName, expiryDate, daysUntilExpiry
directory.credential.expiredcredentialId, userId, organisationId | null, credentialType, credentialName, expiryDate
directory.qualification.updatedorganisationId, qualificationType, status
directory.user_credential.updateduserId, 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.

EventPayload (key fields)
catalog.entry.createdentryId, name, category
catalog.entry.updatedentryId, changes
catalog.entry.deletedentryId
catalog.entry.status_changedentryId, previousStatus, newStatus
catalog.taxonomy.createdcategoryId, code, name, parentPath
catalog.taxonomy.updatedcategoryId, changes
catalog.taxonomy.deletedcategoryId, code
catalog.taxonomy.movedcategoryId, code, oldParentPath, newParentPath
catalog.shortlist.createdshortlistId, name
catalog.shortlist.updatedshortlistId, changes
catalog.shortlist.deletedshortlistId
catalog.shortlist.archivedshortlistId
catalog.shortlist.entry_addedshortlistId, entryId
catalog.shortlist.entry_removedshortlistId, 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.

EventPayload (key fields)
wiki.space.createdspaceId, tenantId, name, slug — see wiki.events.ts
wiki.space.deletedspaceId, tenantId, deletedAt, slug?, pageCount? — see wiki.events.ts
wiki.page.createdpageId, tenantId, slug, parentId | null, classification, ownerUserId | null
wiki.page.updatedpageId, tenantId, revisionNum, changedFields[], classification
wiki.page.publishedpageId, tenantId, fromStatus, toStatus, revisionNum, classification
wiki.page.archivedpageId, tenantId, revisionNum
wiki.page.deletedpageId, tenantId, deletedAt
wiki.page.restoredpageId, tenantId, parentId | null, classification, restoredAt
wiki.link.addedlinkId, tenantId, sourceId, targetId, linkType
wiki.link.removedlinkId, tenantId, sourceId, targetId, linkType
wiki.attachment.uploadedattachmentId, 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).

EventPayload (key fields)
projects.project.status_changedprojectId, fromStatus, toStatus
projects.project.exportedprojectId, format, taskCount, stageCount
projects.project.importedprojectId, format, tasksCreated, tasksUpdated, stagesCreated
projects.programme.progress_updatedprogrammeId, progress (0–100) — legacy; emitted alongside the initiative event during the PLT-182 dual-emit window
projects.initiative.progress_updatedinitiativeId, progress (0–100) — successor to the programme event
projects.stage.progressedstageId, projectId, fromStatus, toStatus
projects.gate.reviewedgateId, 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).

EventPayload (key fields)
projects.task.completedtaskId, projectId, completedBy
projects.task.recurring_spawnedsourceTaskId, newTaskId, projectId, recurrenceIntervalDays

Project Tracker — milestones

Source: packages/contracts/src/events/projects.ts.

EventPayload (key fields)
projects.milestone.overduemilestoneId, projectId, organisationId, title, dueDate, overdueAt, paymentAmount?, paymentCurrency?

Project Tracker — issues

Source: packages/contracts/src/events/issues.ts.

EventPayload (key fields)
projects.issue.createdissueId, projectId, severity, reportedBy
projects.issue.status_changedissueId, projectId, fromStatus, toStatus, changedBy
projects.issue.assignedissueId, projectId, assigneeId, assignedBy
projects.issue.escalatedissueId, projectId, fromSeverity, toSeverity, escalatedBy
projects.issue.resolvedissueId, projectId, resolvedBy, resolution (FIXED / WONT_FIX / DUPLICATE / DEFERRED)
projects.issue.comment_addedissueId, commentId, authorId, isInternal
projects.issue.sla_breachedissueId, breachType (FIRST_RESPONSE / RESOLUTION), targetDeadline, actualAt
projects.issue.portal_ticket_createdissueId, projectId, severity, reportedBy, source: 'PORTAL', categoryId?
projects.issue.promotedissueId, taskId, taskKey | null, projectId, mode (create / link), sourceIssueClosed, actorId, tenantId
projects.issue.follower_changedissueId, userId, action (followed / unfollowed), tenantId, actorId
projects.issue.movedissueId, fromProjectId, toProjectId, fromIssueKey, toIssueKey, actorId, tenantId
projects.issue.forwardedissueId, projectId, fromUserId, toUserId, previousAssigneeId | null, note | null, tenantId, actorId
projects.macro.createdmacroId, title, isInternal, tenantId, actorId
projects.macro.updatedmacroId, changes (title? / isInternal? / bodyChanged?), tenantId, actorId
projects.macro.deletedmacroId, 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).

EventPayload (key fields)
projects.risk.createdriskId, projectId, likelihood, impact, raisedBy
projects.risk.status_changedriskId, projectId, fromStatus, toStatus, changedBy
projects.risk.mitigatedriskId, projectId, mitigatedBy, mitigationNotes
projects.risk.acceptedriskId, projectId, acceptedBy, justification
projects.risk.reassessedriskId, projectId, oldLikelihood, newLikelihood, oldImpact, newImpact, assessedBy

Project Tracker — deliverables

Source: packages/contracts/src/events/deliverables.ts.

EventPayload (key fields)
projects.deliverable.createddeliverableId, projectId, authorId
projects.deliverable.submitteddeliverableId, projectId, authorId
projects.deliverable.review_starteddeliverableId, projectId, reviewerId, startedBy
projects.deliverable.accepteddeliverableId, projectId, reviewerId
projects.deliverable.rejecteddeliverableId, projectId, reviewerId, notes
projects.deliverable.updateddeliverableId, projectId, updatedBy, fields[]
projects.deliverable.deleteddeliverableId, projectId, deletedBy
projects.deliverable.status_changeddeliverableId, projectId, fromStatus, toStatus, changedBy

Project Tracker — feedback

EventPayload (key fields)
projects.feedback.status_changedfeedbackId, fromStatus, toStatus, githubIssueId | null
projects.feedback.promotedfeedbackId, 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.

EventPayload (key fields)
audit.entry.createdtenantId, 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.

EventPublisherPayload (key fields)
coordinator.token_usage.recorded@constellation-platform/coordinatortoken-usage.tstenantId, 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/coordinatorconsult.ts publishConsultSynthesizedconsultId, 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 (persistConsultpublishConsultSynthesized). 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 nameEmitted when
coordinator.consult.createdA coordinator consult row is written (PLT-170, AC 4).
coordinator.pending_action.pendingprepareMutation() stages a new coordinator.pending_actions row (PLT-179).
coordinator.pending_action.executingRow atomically claimed (pending → executing) before the side effect — the exactly-once gate blocking double-submit + non-author execution.
coordinator.pending_action.executedThe claimed side effect succeeded; carries executedUrl.
coordinator.pending_action.cancelledA pending action is cancelled before execution.
coordinator.pending_action.expiredThe TTL sweep (coordinator.expire_pending_actions()) marks a pending row past expires_at.
coordinator.pending_action.failedThe side effect failed; the row is reverted executing → pending (retryable) and records error.