Skip to content

Contracts

Cratis.Showrunner.Usher.Contracts holds the public events an application and a support desk exchange about issues. An application gets them through Cratis.Showrunner.Usher, which publishes the application’s side and translates the desk’s side. A desk, such as Direct, references the contracts package on its own to read what applications report and to answer.

Usher records every fact about an issue as a private event in the application, in the tenant’s own namespace. The private events name the actor (ReporterFlaggedError, SupportAcceptedIssue). The public events in this package name what happened to the issue (ErrorReported, IssueAccepted). A publisher turns the reporter’s private facts into public events in the application’s outbox. A translator turns the desk’s public events back into private Support… facts. The mapping at the end of this page lists every pair.

  • The issue id is the event source id. Every event, in both directions, is appended under the issue’s IssueId: a GUID in its lowercase, hyphenated D form, such as 8f14e45f-ceea-467a-9575-7c0a9b6b0c2d. The id is event context, not a field in the payload. A desk answers under the same id it received.
  • Every payload carries TenantId. It is the name of the tenant’s Chronicle namespace in the application. A desk serves many tenants, and the application’s translator appends each desk event in the namespace that TenantId names.
  • Nothing is nullable. An optional collection is empty, and a value that is not known is the concept’s NotSet, an empty string. A part of a SliceLocation that Arc cannot resolve is NotSet, not missing.
  • No timestamps that repeat the event’s own time. When something happened is the event’s Occurred in its context. On a Chronicle kernel 19.22.1 or later, a forwarded event keeps the time it was recorded as Occurred in the receiving inbox. The only time in a payload is ValidUntil on AccessGranted, which is a business deadline, not the time of the event.
  • Every event is [Public], and none names an origin store. There is no [EventStore] attribute on any contract or on the assembly, because store names vary by deployment. Both sides subscribe explicitly; see Subscribe to the other side.

Unless noted, the concepts are ConceptAs<string>. Namespaces are relative to Cratis.Showrunner.Usher.Contracts; TenantId and IssueId sit in the root namespace.

The application publishes these from its outbox. They describe what the reporter did, and the expiry of a grant.

EventNamespacePayload
ErrorReportedReportingTenantId: TenantId; Location: SliceLocation; CorrelationIds: IEnumerable<CorrelationId>; StackFrames: IEnumerable<StackFrame>; ErrorMessage: ErrorMessage; ErrorType: ErrorType; Fingerprint: ErrorFingerprint; ApplicationName: ApplicationName; ApplicationVersion: ApplicationVersion
IssueReportedReportingTenantId: TenantId; Location: SliceLocation; CorrelationIds: IEnumerable<CorrelationId>; Summary: Summary; Description: Description; ApplicationName: ApplicationName; ApplicationVersion: ApplicationVersion
IssueAttributedToReporterIdentityTenantId: TenantId; Reporter: Reporter
DetailsAddedConversationTenantId: TenantId; Details: Details
QuestionAnsweredConversationTenantId: TenantId; QuestionId: QuestionId; Answer: Answer
IssueReopenedLifecycleTenantId: TenantId; Reason: ReopenReason
IssueWithdrawnLifecycleTenantId: TenantId
AccessGrantedAccessTenantId: TenantId; ValidUntil: ValidUntil
AccessRevokedAccessTenantId: TenantId
AccessExpiredAccessTenantId: TenantId
  • An issue starts with ErrorReported or IssueReported. What the reporter writes alongside an error report follows as DetailsAdded.
  • CorrelationIds holds the correlation ids of the failing command or query and of those run just before it in the reporter’s view. It can be empty. StackFrames is empty when the error had no stack trace, which is usual for errors raised from the frontend.
  • Fingerprint is computed by the application, so the same error in the same slice and version always gives the same value. A desk uses it to group duplicates and otherwise treats it as opaque.
  • IssueWithdrawn also ends support’s access to the issue. No AccessRevoked follows it; a desk treats it as the end of any grant.
  • IssueAttributedToReporter is published only if the reporter chooses to share who they are. Until then the desk knows the issue but not the person. Granting access always shares identity first, so IssueAttributedToReporter comes before AccessGranted on the same issue.
  • AccessExpired is published when ValidUntil passes without the grant being revoked.

The desk publishes these from its outbox. They describe what support did with the issue.

EventNamespacePayload
IssueTriagedLifecycleTenantId: TenantId
IssueAcceptedLifecycleTenantId: TenantId
IssueClosedAsWontFixLifecycleTenantId: TenantId; Reason: WontFixReason
QuestionAskedConversationTenantId: TenantId; QuestionId: QuestionId; Question: QuestionText; Author: SupportAuthor
ReplySentConversationTenantId: TenantId; Reply: ReplyText; Author: SupportAuthor
IssueLinkedToKnownIssueKnownIssuesTenantId: TenantId; KnownIssueId: KnownIssueId; Title: KnownIssueTitle
IssueUnlinkedFromKnownIssueKnownIssuesTenantId: TenantId; KnownIssueId: KnownIssueId
IssueLinkedToWorkItemWorkItemsTenantId: TenantId; WorkItem: WorkItem
IssueUnlinkedFromWorkItemWorkItemsTenantId: TenantId; WorkItemReference: WorkItemReference
WorkStartedLifecycleTenantId: TenantId
IssueResolvedLifecycleTenantId: TenantId; Resolution: Resolution
IssueFixedInReleaseWorkItemsTenantId: TenantId; ReleaseVersion: ReleaseVersion; WorkItemReference: WorkItemReference
AccessRequestedAccessTenantId: TenantId; Reason: AccessRequestReason
  • A triaged issue is decided next: IssueAccepted or IssueClosedAsWontFix.
  • The desk mints the QuestionId with QuestionAsked. The reporter’s QuestionAnswered carries it back, so every answer targets one question.
  • QuestionAsked and ReplySent carry their Author, because the desk’s own causation does not cross into the application. The application records a translated event under its own causation, so the payload is the only place the reporter’s view can learn who wrote. The desk decides what the reporter sees: a name, or SupportAuthorName.NotSet when it hides the names of its people, which the application shows as “Support”.
  • A known issue is the desk’s group of duplicate reports across tenants. Each report stays its own issue in its own tenant; the application only sees the known issue’s id and title.
  • IssueResolved carries only the resolution note. A fix is often resolved when merged and released later, so the release that ships it is its own event: IssueFixedInRelease names the version and the work item the release closed.
  • AccessRequested asks the reporter for a grant, and the reporter answers with AccessGranted or not at all. Support sessions under a grant are recorded by the application itself and have no public event.
TypeNamespaceShape
TenantIdrootThe tenant’s namespace name
IssueIdrootEventSourceId<Guid>; the event source id, never in a payload
SliceLocationReportingModule: ModuleName; Feature: FeatureName; Slice: SliceName. SliceLocation.NotSet has every part empty.
CorrelationIdReportingConceptAs<Guid>. Usher’s own concept, separate from Cratis.Execution.CorrelationId: it is a value the reporter’s application ran, not the correlation of the Usher event. The two convert through their Guid.
StackFrameReportingMethod: StackFrameMethod; File: StackFrameFile; Line: StackFrameLine (ConceptAs<int>, one-based, 0 when not reported). Top frame first.
ErrorFingerprint, ErrorMessage, ErrorType, ApplicationName, ApplicationVersion, Summary, DescriptionReportingText
ReporterIdentityIdentityProvider: IdentityProviderName; IdentityProviderSubject: IdentityProviderSubject; Name: ReporterName ([PII]); Email: Email ([PII])
QuestionIdConversationConceptAs<Guid>
SupportAuthorConversationName: SupportAuthorName; Kind: SupportAuthorKind (Person = 0, Agent = 1)
Details, Answer, QuestionText, ReplyTextConversationText
ReopenReason, WontFixReason, ResolutionLifecycleText
KnownIssueIdKnownIssuesConceptAs<Guid>
KnownIssueTitleKnownIssuesText
WorkItemWorkItemsTracker: Tracker; Reference: WorkItemReference; Title: WorkItemTitle; Url: WorkItemUrl (NotSet when the reporter cannot open it; only an absolute https or http address is kept)
ReleaseVersionWorkItemsText, for example 4.12.3
ValidUntilAccessConceptAs<DateTimeOffset>; the fixed end of a grant
AccessRequestReasonAccessText

What the reporter writes, and who they are, is classified [PII] on the shared concepts themselves, so it is encrypted in both stores:

ConceptWhy
Summary, Description, Details, Answer, ReopenReasonFree text the reporter writes; it can name or identify them
ErrorMessageAn application can put what the reporter entered into an error message
ReporterName, EmailWho the reporter is
IdentityProviderSubjectA pseudonymous identifier of the reporter. It is still personal data, so erasing the reporter makes it unreadable too; IdentityProviderName names the provider, not the person, and is not classified

SupportAuthorName, QuestionText, ReplyText and the other texts the desk writes are not classified: they are the desk’s, and a desk that treats the names of its people as personal data sends NotSet.

The compliance subject is the reporter, not the issue. Every fact the reporter records is appended with the subject usher-reporter-<sha-256 of the identity provider and subject>, and the publisher publishes each contract event with the subject of the private event it comes from. One erasure of that subject therefore covers everything a person wrote on all of their issues. The subject is a one-way hash because it travels unencrypted with every event, and the desk is not meant to learn who reported an issue before the reporter shares who they are. Events Usher or the desk record (a grant lapsing, a session, the desk’s replies) keep the issue as their subject.

The desk reads forwarded events in the clear. Encryption keys are per event store and namespace. When Chronicle forwards an event from the application’s outbox to the desk’s inbox, it copies the subject’s key from the application’s store into the desk’s store, in the same namespace, the first time it forwards an event of that subject, and the desk’s inbox stores the content encrypted under that copy. So the desk decrypts what it receives like its own events, and the classification can live on the shared concepts. Erasure is per store: erasing the reporter in the application deletes the application’s key, and a desk that must forget them too deletes the same subject in its own store. Chronicle never copies a key into a store that erased it.

The publisher adds TenantId to every public event and publishes the reporter only after ReporterSharedIdentity.

Private event in the applicationPublic event to the desk
ReporterFlaggedErrorErrorReported
ReporterDescribedIssueIssueReported
ReporterSharedIdentityIssueAttributedToReporter
ReporterAddedDetailsDetailsAdded
ReporterAnsweredQuestionQuestionAnswered
ReporterReopenedIssueIssueReopened
ReporterWithdrewIssueIssueWithdrawn
ReporterGrantedAccessAccessGranted
ReporterRevokedAccessAccessRevoked
AccessGrantLapsedAccessExpired

The translator appends each desk event under the same issue id, in the namespace that TenantId names. It ignores an issue id it does not know, and a redelivered event changes nothing.

Public event from the deskPrivate event in the application
IssueTriagedSupportTriagedIssue
IssueAcceptedSupportAcceptedIssue
IssueClosedAsWontFixSupportDeclinedToFix
QuestionAskedSupportAskedQuestion
ReplySentSupportReplied
IssueLinkedToKnownIssueSupportLinkedIssueToKnownIssue
IssueUnlinkedFromKnownIssueSupportUnlinkedIssue
IssueLinkedToWorkItemSupportLinkedIssueToWorkItem
IssueUnlinkedFromWorkItemSupportUnlinkedWorkItem
WorkStartedSupportStartedWork
IssueResolvedSupportResolvedIssue
IssueFixedInReleaseReleaseShippedFix
AccessRequestedSupportRequestedAccess

SupportSignedInAsReporter and SupportSessionEnded have no public event. The application records them itself when support signs in on the reporter’s behalf, with both identities in the causation.

Nothing in the contracts package routes an observer to the other side. Each side keeps the name of the other side’s store in its own configuration and does two things at startup:

  1. Subscribe its own store to the other store’s outbox. Chronicle forwards the listed event types into inbox-{other store} on the subscribing store, in the same namespace. Use the other store’s name as the subscription id, and filter the union of the event types its consumers need.
  2. Observe inbox-{other store} with observers that name that event sequence. An observer that names no sequence reads the store’s own event log and never sees the other side’s events.

An application gets this from Cratis.Showrunner.Usher: it subscribes to each store in DeskStores (Direct by default) for the desk-to-application events, and its translator observes inbox-{desk store}. Chronicle forwards only into namespaces the subscribing store has, so a desk opens its store in each tenant namespace of the application; see Setting up a desk. A desk does the same for each application store it serves:

await store.Subscriptions.Subscribe(
new EventStoreSubscriptionId(applicationStore),
applicationStore,
subscription => subscription
.WithEventType<ErrorReported>()
.WithEventType<IssueReported>()
.WithEventType<IssueAttributedToReporter>()
.WithEventType<DetailsAdded>()
.WithEventType<QuestionAnswered>()
.WithEventType<IssueReopened>()
.WithEventType<IssueWithdrawn>()
.WithEventType<AccessGranted>()
.WithEventType<AccessRevoked>()
.WithEventType<AccessExpired>());

Here store is the desk’s own IEventStore and applicationStore is the configured name of the application’s store. Projections, reducers and reactors over the application’s events then name the inbox-{application store} sequence, for example [Reactor(eventSequence: "inbox-Ledger")] for an application store named Ledger. Consumers on both sides must tolerate the same event arriving twice, because the forwarder can deliver it again.