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.
  • Looking up an audit action name? Audit actions are not domain events and have no event schema of their own. One written with auditCritical() is published inside the single audit.entry.created event; one written with auditAction() is published nowhere and is found only by querying the audit log. The ones a module reserves or a scheduled job emits are listed in their own subsections, marked as audit actions: coordinator and wiki.
  • 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.

    Declare the dispatcher identity, or every tick refuses. Since PLT-1046 the shared entrypoint asserts ADR-036 § D2's conditions against the cluster before it claims a batch: the connection must be one of the logins this environment has declared, and platform_dispatcher must have no member it did not declare. Set DISPATCHER_LOGIN_ROLES to the comma-separated logins you permit (and DISPATCHER_APPLIER_ROLE where a managed platform holds the membership by its own CREATEROLE auto-grant — postgres on Supabase). With nothing declared the route answers 500 with a stable refusal code, which is the fail-closed direction: the constitution §1 cross-tenant exception is not granted to an environment that has named no dispatcher. An app that runs no dispatcher at all is unaffected — an unset DISPATCHER_DATABASE_URL still answers 200 skipped. The confinement is asserted too, so the DSN must not be a superuser or a BYPASSRLS role, and the full variable reference is in docs/operations/env-vars.md.

  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.role.deletedroleId
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.unarchivedpageId, tenantId, revisionNum, toStatus, classification
wiki.page.deletedpageId, tenantId, deletedAt
wiki.page.restoredpageId, tenantId, parentId | null, classification, restoredAt
wiki.page.reorderedpageId, tenantId, classification
wiki.link.addedlinkId, tenantId, sourceId, targetId, linkType, classification?
wiki.link.removedlinkId, tenantId, sourceId, targetId, linkType
wiki.attachment.uploadedattachmentId, tenantId, pageId | null, mimeType, sizeBytes, classification

wiki.link.added (PLT-1029) — classification is optional and additive, and it is max(source, target): a page_links row is visible only to a caller cleared for both endpoints, so that maximum is the link's authoritative admission tier. Consumers fail closed on absence — an absent tier is not UNCLASSIFIED. The field is the ADR-035 clause 2 publisher obligation, and it is necessary but not sufficient: it is stamped from endpoint rows read without a row lock, and no link event is emitted at all when an endpoint is later reclassified upward. The event is therefore deliberately kept subscriber-free, pinned by a test, until a link reclassification/withdrawal mechanism exists. wiki.link.removed stays tier-free on purpose — a withdrawal admits nothing, so a tier would disclose for no consumer benefit.

wiki.page.reordered (PLT-527) — emitted once per successful manual reorder, and on every success, never zero-versus-one depending on what the rank allocator had to do. That uniformity is the point rather than tidiness: the allocator's internal branches depend on rows the caller may not be cleared to see, so an event that appeared only sometimes would make those rows observable. One event type covers both a same-parent reorder and an anchored re-parent, for the same reason.

The payload is deliberately the minimum, and each absence is load-bearing (ADR-030 O1 and O9). It carries no position — the stored ordering key is projected to nobody, because a key's value, and even its length, carries the history of higher-classified siblings. It carries no parent in either direction, and that is the one absence worth reading twice. A page's stored parent_id may name a page above the actor's clearance — RLS filters rows and cannot mask a column, and PageService.projectForViewer is what nulls it on a read — so an absolute parent would publish a classified page's id to every subscriber, which the page's OWN classification does not gate: an UNCLASSIFIED page under a SECRET parent passes every admission rule. An actor-relative parent is no better, because it would make a durable contract mean different things depending on who moved the page — wiki.page.restored says as much about itself. A consumer that needs the page's new place in the tree reads back under its own scope, which it must do for the ordering anyway. It carries no anchor id, which describes how the caller expressed the move rather than any fact about the page afterwards. classification is the publisher-side obligation of ADR-035: delivery is trusted-in-tenant and not clearance-filtered, so the compensating control is that the payload states the authoritative tier and each subscriber applies its own admission rule. The same redaction applies to the routine auditAction entry the move writes, which passes classification at the top level so the audit read policy does not treat a classified page's reorder as unclassified.

wiki.page.unarchived (PLT-931) — emitted when an archived page is brought back into the authoring flow. archived used to be a terminal state; it now has exactly one outgoing edge, back to draft, taken through an explicit confirmed Unarchive action in the editor. The event exists because wiki.page.updated cannot express the reversal on its own: its changedFields says ['status'] without naming the destination, so an archival projection could not tell an unarchive from any other status move. toStatus is therefore the point of the payload, and classification rides along because the two surfaces this makes the page eligible for again — knowledge-base answers and related-page suggestions — filter on status and classification, so a consumer would otherwise need a read-back to act on it. Note the asymmetry: archiving excludes the page from both outright, while unarchiving only lifts that exclusion — each surface applies further predicates of its own, so a consumer must not read this event as "the page is now being returned". There is deliberately no fromStatus: it is always archived, which is what the event name says. toStatus is typed as the page-status vocabulary, not a free-form string — unlike wiki.page.published above, whose fromStatus/toStatus stay z.string() because that event is shipped and narrowing it would retroactively reject historical outbox rows. A new event pays nothing for the constraint; an old one would. It is the vocabulary rather than the literal draft so that a future second exit from archived is an additive change rather than a breaking one. No cross-module subscriber today, so no mirror in packages/contracts — only events consumed outside apps/wiki are mirrored there. The write is also recorded in audit.audit_entries under action: 'wiki.page.unarchived' through the critical channel, with the before/after status pair in changes; archiving itself keeps its routine UPDATE entry.

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']).

Wiki audit-action names emitted by scheduled jobs​

These are audit-log entries, not domain events. None is in wiki.events.ts, none is published as an event of its own, and there is no Zod event schema for any of them — look for one and you will not find it. Each is written with auditCritical() (per audit-logging), which stores the audit row and publishes it as audit.entry.created (see Platform — audit) in the same transaction. To find one, query the audit log by action. No subscriber to audit.entry.created is admitted today (see below); an admitted one would filter on payload.action. They are listed here for the same reason as the coordinator's coordinator.expiry_sweep.* pair: a reader asking "what does a scheduled job record per tenant" should find every module's answer on one page.

Per-tenant sweep entries (ADR-040 § D5). Every wiki cron that discovers tenants across the tenant boundary owes a record of that read. For each tenant a discovery returns, the run owes a …tenant_discovered entry in that tenant's own transaction, before any of its work, including a tenant the run then skips. For each tenant that entry records as admitted, it then owes a …tenant_outcome entry once the work ends. The outcome entry is owed whatever happened, "nothing to do" included, because an entry written only on failure would reveal that the work failed. An owed entry is not guaranteed to exist: a run that reaches its audit cutoff before writing a discovery entry, or cannot attempt or commit an outcome entry, counts the gap in its response as tenantsUnaudited or outcomesUnrecorded. A tenant missing from the log is therefore read against that run's counts, never as proof the tenant was not discovered.

Audit action nameCron (schedule, UTC)Enumerator (changes.operationId)Since
wiki.verification_sweep.tenant_discovered/wiki/api/cron/verify-decisions (30 7 * * *)tenants_with_pending_verifications — wiki.tenants_with_pending_verifications()PLT-1099
wiki.verification_sweep.tenant_outcome/wiki/api/cron/verify-decisionstenants_with_pending_verificationsPLT-1230
wiki.curator_lint.tenant_discovered/wiki/api/cron/curator-lint (0 7 * * *)list_agent_owned_spaces — wiki.list_agent_owned_spaces()PLT-1179
wiki.curator_lint.tenant_outcome/wiki/api/cron/curator-lintlist_agent_owned_spacesPLT-1179
wiki.kb.retention_tenant_discovered/wiki/api/cron/curator-lintkb_reads_tenants_with_expired — wiki.kb_reads_tenants_with_expired(INT), run only when the deadline allowsPLT-1179
wiki.kb.retention_tenant_outcome/wiki/api/cron/curator-lintkb_reads_tenants_with_expiredPLT-1179
wiki.request_change.attempts_retention_tenant_discovered/wiki/api/cron/prune-request-change-attempts (45 7 * * *)request_change_attempts_tenants_with_expired — wiki.request_change_attempts_tenants_with_expired()PLT-1337
wiki.request_change.attempts_retention_tenant_outcome/wiki/api/cron/prune-request-change-attemptsrequest_change_attempts_tenants_with_expiredPLT-1337

All eight share one envelope: actorId is the nil SYSTEM sentinel (00000000-0000-0000-0000-000000000000), actorType is SYSTEM, resourceType is wiki_tenant, resourceId is the tenant id, and correlationId is the run's. They carry no classification, so audit.entry.created publishes their changes in full. Their changes are exactly:

  • discovery — { phase: 'discovery_read', operationId, invocationId, workDisposition: 'admitted' }, or { phase: 'discovery_read', operationId, invocationId, workDisposition: 'skipped', skipReason };
  • outcome — { phase: 'work_outcome', operationId, invocationId, outcome }.

operationId names the enumerator (column above). invocationId identifies one call of it within the run, so the entries are keyed on (tenant, operationId, invocationId): a run that called the same enumerator twice would owe a separate entry for each call. skipReason is 'budget_exhausted' for every sweep; the verification sweep and the curator-lint (agent-owned) discovery can also record 'seeded_agent_unavailable', for a tenant with no seeded agent the sweep may act as. That reason wins over the budget. The retention sweeps run as the SYSTEM sentinel and resolve no agent, so they never record it. outcome is 'completed' (the run began the tenant's work) or 'abandoned' (the run's deadline passed before any of it began). It deliberately does not say whether the work failed or left anything behind. For the verification, curator-lint and KB read-retention sweeps that work touches rows above UNCLASSIFIED, and audit.entry.created is delivered within the tenant without clearance filtering (ADR-035 clause 1), so a failure or a backlog caused by rows a reader cannot see must not reach that reader. The request-change attempts table has no classification column, so that sweep uses the same vocabulary for consistency rather than for that reason. No entry carries a count, error text or any identifier finer than the tenant. Failures are counted in each run's aggregate response instead.

Unlike the coordinator pair, these changes shapes are not mirrored in packages/contracts: no module outside apps/wiki consumes them. They are fixed in verification-sweep.tools.ts + verification-sweep-audit.tools.ts, curator-sweep-audit.tools.ts, and request-change-attempts-retention.tools.ts. The vocabularies are in apps/wiki/src/lib/types/.

Other audit actions from the same crons. Four more that an auditor of these jobs looks for — a scheduled deletion, or a read above the caller's clearance. They are also auditCritical() entries, not domain events. This is not every action the crons write: the curator-lint cron also records, among others, its knowledge-base usage rollups and anomaly detections, which are described with the features that own them.

Audit action nameEmitted when
wiki.kb.retention_prunedA knowledge-base read-retention batch deleted expired reads — resourceType wiki_kb_reads, context: { retentionDays, deletedCount }. Not written for a batch that deleted nothing.
wiki.request_change.attempts_prunedA request-change attempts retention batch deleted expired counters — resourceType wiki_request_change_attempts, context: { horizonSeconds, deletedCount } (PLT-1337). Not written for a batch that deleted nothing.
wiki.lint.verification_crossing_read_performedA finding verification ran checks that read above the caller's clearance. The verification sweep writes one per space pass that ran the lint and whose transaction commits, and a request-path verification writes one too. A pass that rolls back writes none, and no per-pass entry survives the rollback. The tenant's wiki.verification_sweep.tenant_outcome entry still records that its work began, but it names no space and no read. Filed at RESTRICTED on the space. See Wiki lint.
wiki.lint.classification_scan_performedA space lint or a verification ran the classification reconcile, which reads pages above the caller's clearance. Written on every run, whatever it found. Filed at RESTRICTED (PLT-1416). See Wiki lint.

All four put their detail in the audit row's context column rather than in changes, and audit.entry.created does not carry context. A subscriber receives the standard envelope — action, actor, resource, module, correlation id, classification — but not those details; they are read from the audit log.

Project Tracker — projects & initiatives​

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_updatedLegacy — no projects.programmes table exists. programmeId, progress (0–100); emitted alongside the initiative event below during the PLT-182 dual-emit window for existing subscribers only
projects.initiative.progress_updatedinitiativeId, progress (0–100) — successor to the legacy 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 & initiatives section).

EventPayload (key fields)
projects.task.completedtaskId, projectId, completedBy
projects.task.recurring_spawnedsourceTaskId, newTaskId, projectId, recurrenceIntervalDays
projects.task.unblockedtaskId (the now dependency-clear successor), projectId, tenantId, unblockedByTaskId (the completed blocker), completedBy — one event per (successor, blocker) pair when a completion leaves every BLOCKS-predecessor of the successor done (fully-clear, matching the flow-engine ready set)
projects.task.batch_updatedtenantId, actorId, correlationId, taskIds[] (max 100), count (must equal taskIds.length), patchFields (status / assigneeId / dueDate / priority booleans, plus an optional cycleId boolean added by PT-950 — absent on events published before it shipped, so read absent as false) — published by bulkUpdateTasksTool (PT-258)
projects.task.movedtaskId, tenantId, fromProject, toProject, oldKey, newKey, originalStatus, newStatus, byUser, at, correlationId

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
projects.feedback.promoted_to_taskfeedbackId, taskId, taskKey, projectId, 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.

No subscriber is admitted today (PLT-1301). subscribe('audit.entry.created', …) throws unless the handler name is in ADMISSION_ALLOWLIST in packages/platform/events/src/subscribe.ts, and that list is empty. Admitting one is a reviewed act under ADR-035 clauses 4 and 5. Until one is admitted, every audit action on this page is read by querying the audit log by action. Where this page says a subscriber would filter on payload.action, it describes what an admitted subscriber receives, not something you can register now.

changes is minimised when the entry carries a tier (PLT-1029). When classification is non-null and ranks above UNCLASSIFIED — or is not on the ladder at all, which minimises rather than ranking as unclassified — the published changes is replaced by the reserved marker __auditChangesMinimized: true plus only the top-level keys an explicit per-call allowlist names. The marker is never caller-authored: it is excluded from the allowlist and written last. A minimised payload is distinguishable from an absent one, which stays null. The durable audit row is untouched and keeps the full changes: constitution §5 makes the row the source of truth, and auditAction() runs first against the caller's unmodified options. Note the consequence for wiki: every wiki entry carrying a tier carries it at the SIEM floor, so every wiki changes payload is minimised on the event, including one describing an UNCLASSIFIED page.

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/coordinator — token-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/coordinator — consult.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), actorType? (optional, PT-798)

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. A successful, initiative-scoped consult on an UNCLASSIFIED initiative is eligible (persistConsult → publishConsultSynthesized); eligibility alone is not enough, because the quality gates below still apply. The payload carries the full answer text + citations so the subscriber needs no read-back call to PT. Emission is gated twice. The safety floor: tenant-wide consults, error consults, and consults on initiatives classified above UNCLASSIFIED never emit (classification-leak guard — the KB space and the outbox payload are both UNCLASSIFIED surfaces). The quality gate on top of it: an ordinary consult additionally needs at least one citation and a non-ephemeral durability verdict, so a zero-citation answer does not emit either (see durability below for the retrospective exception). 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 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). Cycle-close retrospectives (PT-798) are the one exception to both rules: synthesizeCycleRetrospective() emits with durability forced to durable — whatever the model's own verdict, ephemeral included — and with zero citations, because a retro synthesizes over cycle statistics rather than KB pages. Every other gate above (successful parse, initiative-scoped, UNCLASSIFIED) applies to a retro unchanged. The optional actorType field (PT-798, additive — 'USER' | 'AGENT' | 'SYSTEM') says what KIND of actor produced the consult; it is omitted for an ordinary interactive consult, so pre-PT-798 payloads are byte-identical. Note that userId keeps its published meaning throughout — the account the consult was submitted under, which for a system-fired retrospective is the initiative delegate whose membership satisfied the insert policy. A consumer that needs to know a human did not type it reads actorType, not a sentinel in userId: redefining an existing field would break the append-only contract. 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) except where a row below says auditAction(), which publishes nothing at all — not as outbox domain events, so none of them is published via events.publish(). The changes payload shape IS contracted in packages/contracts/src/events/coordinator.ts (CoordinatorPendingActionAuditSchemas, CoordinatorExpirySweepAuditSchemas for the two coordinator.expiry_sweep.* names, and CoordinatorCapabilityGapAuditSchemas for the capability-gap names) so consumers can validate it. The canonical names are reserved here so future cross-module subscribers cannot collide on the namespace. To find them, query the audit log by action. No subscriber to audit.entry.created is admitted today (see Platform — audit); an admitted one would filter on payload.action.startsWith('coordinator.pending_action.') or payload.action.startsWith('coordinator.expiry_sweep.'). 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 hourly sweep marks a pending row past expires_at (or an executing row whose lease elapsed) — per tenant, via coordinator.expire_pending_actions_for_tenant(), in the SAME transaction as the entry (PLT-1121).
coordinator.pending_action.failedThe side effect failed; the row is reverted executing → pending (retryable) and records error.
coordinator.expiry_sweep.tenant_discoveredThe hourly expiry sweep's discovery read returned this tenant — one entry per tenant per discovery invocation, in that tenant's own transaction before any of its work; changes says whether it was admitted or skipped for want of budget (PLT-1137).
coordinator.expiry_sweep.tenant_outcomeAn admitted tenant's sweep work ended — completed, failed or deadline_reached — whatever it found, in that tenant's own transaction (PLT-1137).
coordinator.ingest_binding.reprovisionedOne agent-control-plane provisioning transaction created, changed or reconciled ingest bindings and their namespace claims — one entry per transaction, however many bindings it touched (PLT-837).
coordinator.capability_gap.filedAn agent capability-gap filing landed on a REGISTERED operation — routine (auditAction()), resourceId is the occurrence id (PLT-694).
coordinator.capability_gap.proposal_filedA filing proposed a novel missing operation — auditCritical() of one invariant shape whether or not the proposal already existed; no case id, key or tier (PLT-694).
coordinator.capability_case.promotedA proposal-backed capability case was promoted in place to a registered member — auditCritical(), resourceId is the case's audit_ref pseudonym (PLT-694).
coordinator.capability_case.transitionedA triager moved a capability case along a declared edge — auditAction(), keyed by the case id, or by audit_ref only for a proposal-backed case (PLT-694).
coordinator.capability_case.proposal_disclosedA triage read disclosed proposed keys classified above INTERNAL — auditCritical(), one entry per tier per read, classified at that tier; resourceType coordinator.capability_case_disclosures, resourceId the entry's disclosureId (PLT-694).

PLT-837 — one ingest-binding entry per provisioning decision. reprovisionIngestBindings() / provisionIngestBinding() in packages/platform/coordinator/src/ingest-bindings.ts are the only write path for coordinator.ingest_bindings and coordinator.ingest_namespace_claims, and each transaction that changes anything emits exactly one coordinator.ingest_binding.reprovisioned entry, attributed to the provisioning actor, with resourceType coordinator.ingest_bindings and resourceId equal to changes.batchId. Released claim rows are deleted, so the entry is their only history: changes.bindings[] carries each affected binding's bindingId, serviceUserId, change (created / updated / reconciled), full before / after grant, and the namespace keys it acquired and released; changes.moves[] names every key that changed hands with its fromBindingId and toBindingId, so a transfer reads as one decision rather than an unrelated release and claim. A batch that changes nothing writes no entry. Contract: CoordinatorIngestBindingAuditSchemas (strict). An admitted audit.entry.created subscriber would filter on payload.action.startsWith('coordinator.ingest_binding.'); none is admitted today, so read the audit log.

PLT-694 — capability-gap entries are existence-neutral. recordCapabilityGap(), transitionCapabilityCase() and the triage reads in packages/platform/coordinator/src/capability-gaps.ts write these five names in the same transaction as the change or read they record. A registered filing writes coordinator.capability_gap.filed with changes { occurrenceId, caseId, surface, registeredOperation }. A proposal-backed filing writes coordinator.capability_gap.proposal_filed with exactly { occurrenceId, surface, operation: 'proposal' }, and the entry has the same shape whether the proposal was new or known, so the audit trail is no oracle for a guessed key. No filing entry carries the proposed key, its digest, a context UUID or digest, or a proposal's classification tier. A promotion writes { auditRef, change: 'promoted' }, and a transition writes { caseId | auditRef, from, to, linkedIssue }, where linkedIssue is unchanged, linked or unlinked and never the PT issue UUID. A proposal-backed case is named ONLY by its audit_ref pseudonym, both in changes and in resourceId. A triage read that returns proposed keys classified RESTRICTED, CONFIDENTIAL or SECRET writes one coordinator.capability_case.proposal_disclosed entry per disclosed tier — at most three per read — with { disclosureId, auditRefs, classification, viewerId }, where auditRefs lists every audit_ref disclosed at that tier and resourceId equals disclosureId — so "who viewed case X" is a search of changes.auditRefs for X's audit_ref, not a resourceId lookup. The entry's own classification column is that tier, so auditCritical() minimises its published changes (PLT-1029): the coordinator allowlists all four keys, none of them classified content, and the event adds __auditChangesMinimized: true. Contract: CoordinatorCapabilityGapAuditSchemas (strict) describes the audit ROW of all five names, and CoordinatorCapabilityGapPublishedChangesSchemas the published changes of the three that publish (the disclosure's adds the minimisation marker to its row). Only the three auditCritical() names above publish audit.entry.created, so a subscriber filtering payload.action.startsWith('coordinator.capability_gap.') or 'coordinator.capability_case.' receives proposal_filed, promoted and proposal_disclosed — never filed or transitioned, the two highest-volume names. auditAction() writes the row and publishes nothing (packages/platform/db/src/audit.ts calls no publish()), so those two are read by querying the audit log, not by subscribing. Do not read their absence from a subscription as "no filings happened".

PLT-1121 — the expired entry is atomic with the state change. The sweep (expirePendingActions(), called by the PT coordinator-expire-pending-actions cron) discovers the tenants holding an expirable row through coordinator.list_pending_action_expiry_tenants() — callable only by the coordinator_expiry_sweeper role — and then drains the tenants round-robin in bounded batches, each batch running the UPDATE and every coordinator.pending_action.expired emission in ONE tenant-scoped transaction. A row is never expired without its entry: if the audit or outbox write fails, that batch's transitions roll back (earlier batches stay committed) and the next sweep retries them. The entry is attributed to the SYSTEM sentinel actor (00000000-…) and its changes.tenantId is the tenant of the row; a consumer must never see an expired entry whose tenant differs from the row it names. There is no best-effort or "audit may be lost" mode.

PLT-1137 — every discovered tenant is audited, not only the ones with work. The discovery read is itself recorded (ADR-041 § D4, following ADR-040 § D5). Each tenant it returned gets a coordinator.expiry_sweep.tenant_discovered entry, committed in that tenant's own transaction before any of its work. Each tenant admitted to work gets a coordinator.expiry_sweep.tenant_outcome entry once its outcome is known, including a tenant whose rows were settled before its batch ran. Both are attributed to the SYSTEM sentinel, with resourceType coordinator_tenant and resourceId the tenant id. Their changes are exactly { enumerator, invocationId, workDisposition: 'admitted' } or { enumerator, invocationId, workDisposition: 'skipped', skipReason: 'budget_exhausted' } on a discovery entry, and { enumerator, invocationId, outcome: 'completed' | 'failed' | 'deadline_reached' } on an outcome entry, where enumerator is 'coordinator.list_pending_action_expiry_tenants' and invocationId is shared by one discovery call's entries — never a count, a row id or error text (CoordinatorExpirySweepAuditSchemas, strict). A discovered tenant whose entry did not commit, or whose entry the run could not attempt before its audit cutoff, is counted in the cron's response and turns the run red.

PLT-373 — agent-staged rows. An assignable-agent run can stage a destructive proposal as a task-anchored pending action (agent_staged = true). Such rows carry no upstream consult, so their audit changes set upstreamConsultId to the all-zero sentinel and add an optional agentRunId (the coordinator.agent_runs row that staged it) — agentRunId is present with a UUID on agent-staged entries and omitted entirely on consult rows, so its presence is the agent-staged discriminator. The pending entry is attributed to the AGENT (actor_type = 'AGENT'); the executing/executed/cancelled entries are attributed to the confirming HUMAN. Contract: coordinatorPendingActionAuditSchema in packages/contracts/src/events/coordinator.ts (agentRunId: uuid.optional()).