Appearance
Annotations and Transient Annotations
Context
Records that move through our systems (events, commands, requests, projections, resources) need a place to carry caller-attached metadata that the platform itself does not interpret. Examples of the shape of need (not an enumeration of keys) include: cross-cutting observability context, write-time plumbing, caller-owned domain tags, and platform cross-cutting concerns that are not first-class envelope fields.
Two questions have to be answered consistently across every record type:
- Who owns which keys, so callers from different systems and teams can attach metadata without colliding.
- How long does an attached entry live, so storage knows what to persist, and projections know what to copy onto downstream resources.
Existing precedents were considered:
- Kubernetes splits metadata into
labels(selectable, indexed) andannotations(opaque, free-form). The split exists because the API server is the selector engine and has to index a bounded namespace cheaply, and because selector values need bounded, matchable syntax. The split earns its keep exactly where such an engine exists. This ADR does not assume one: it governs the opaque namespace. A system that does operate a selector engine (resolving routing or binding selectors against metadata)MAYadd a separate selectable field for those keys as a downstream decision; that field is out of this ADR's scope and does not change the rules here. - CloudEvents, NATS headers, Kafka headers, AMQP application properties, and HTTP headers all use a single flat map with a reserved-prefix convention.
- Event-sourced systems consistently distinguish between metadata that is part of the event's permanent record and metadata that is attached only for the moment of delivery (tracing context, idempotency, retry plumbing). This distinction maps to real, irreversible system behaviors (persistence and projection), and is decided by the caller at write time.
The axis that earns its keep in our environment is lifetime, not selectability.
This ADR defines only the specification of the annotation namespace. It does not enumerate which keys exist, where any particular concern (tracing, idempotency, correlation, etc.) should be placed, or how individual projections behave. Those are caller and consumer decisions applied using this specification. First-class envelope fields (record identifiers, types, timestamps, schema versions, causation, aggregate identity, and similar platform-interpreted fields) are out of scope and are governed elsewhere.
Resolution
Written records (events, commands, requests) expose two sibling fields with identical shape and identical key rules, distinguished only by lifetime. Resources and projections expose only the first; they never carry a transient_annotations field (see Lifetime contract):
annotations: { <key>: <string>, ... }
transient_annotations: { <key>: <string>, ... } # written records onlyShape
- Both fields are maps of string key to string value.
- Values are opaque to the platform. The platform does not parse, validate, or interpret values. Callers that need structured values
MUSTencode them as strings (for example, JSON-encoded). - Annotation values
MUST NOTuse typed dynamic containers such asmap<string, google.protobuf.Value>,google.protobuf.Struct, or equivalent open-ended JSON value maps as the platform contract. Typed domain data belongs in first-class fields or separately governed schemas. - Both fields are optional. Absence and an empty map are equivalent.
- Caller-attached opaque metadata
MUSTlive in these two fields. RecordsMUST NOTgrow parallel ad-hoc fields for the same purpose. - This specification defines no size or count limits. Systems
MAYimpose documented limits, and propagating consumersMAYfilter entries to stay within them.
Key syntax
Keys follow the Kubernetes label key syntax, by reference:
- A key is either
<name>or<prefix>/<name>. <prefix>, when present,MUSTbe a DNS subdomain (lowercase alphanumerics,-, and.; segments separated by.; total length<= 253characters).<name>MUSTbe1..63characters, beginning and ending with an alphanumeric, with-,_, and.permitted in between.
Ownership and reservation
- A prefix
MUSTbe a DNS subdomain owned by the writer. - Unprefixed keys belong to the caller's own namespace and
MUST NOTbe written by the platform. - Keys under a platform-owned prefix are reserved for that platform. Consumers
MUST NOTwrite to a prefix they do not own. trogondb.com/is reserved for TrogonDB platform-written annotations. CallersMUST NOTwrite keys under this prefix unless they are acting as the platform component that owns the key.- Additional reserved prefixes
MUSTbe documented by the system that owns them before they are used. - Ownership governs authorship: creating, modifying, or removing an entry. Carrying an existing entry forward verbatim during propagation is not authorship and is permitted for keys the propagator does not own.
Prefix identity and stability
A prefix is an opaque identifier that borrows DNS syntax for decentralized uniqueness. It is not a live pointer to a DNS record:
- A prefix does not need to resolve. Ownership is established when the prefix is first documented, per the reservation rules above, not by holding the DNS registration.
- Teams do not need their own domain. Ownership delegates the way DNS does: a team
MAYmint a prefix under a domain its organization owns (for example,payments.example.com/). - Once a key under a prefix has been written, the prefix is permanent. Renaming, losing, or selling the domain later does not transfer or invalidate the prefix, and registering a lapsed domain grants no claim over keys already documented by another writer.
- Because persisted records are immutable, writers
SHOULDmint prefixes on the most stable name available, such as a product or system name rather than corporate branding. Migrating to a new prefix means writing new keys going forward; keys under the old prefix remain valid in persisted records forever andMUST NOTbe rewritten.
Lifetime contract
An operation is a single logical write performed in response to one processing occasion, such as handling one command, one consumed delivery, or one API request, regardless of how many records or streams it writes to.
annotationsMUSTbe persisted as part of the record's permanent representation when the record is persisted.- Follow the mutability of the record they are attached to: immutable on immutable records such as events, updatable on mutable records such as resources, subject to the ownership rules above.
SHOULDbe projected onto downstream resources by default. ProjectorsMAYfilter on a per-rule basis.
transient_annotations- Are attached at write time for the lifetime of the delivery.
- Describe the operation, not an individual record: an operation that writes multiple records
MUSTattach the same entries to every record it writes. MUST NOTbe projected onto downstream resources. This is absolute, not a default: the field does not exist on resources.MAYbe persisted alongside the record for replay and audit purposes. PersistenceMAYbe opted out of per stream or per system when the cost is not justified; the default is to persist.- Are best-effort context. Because persistence can be opted out and propagation can filter, consumers
MUST NOTdepend on an entry being present for correctness.
- Resources and projections
MUST NOTexpose atransient_annotationsfield.
Propagation
Projection and propagation are distinct behaviors. Projection copies metadata from a record onto a downstream resource (a read model), and is governed by the lifetime contract above. Propagation copies metadata from an incoming record onto the new records emitted as a result of processing it, such as a workflow consuming an event and writing the events that result from it.
The two fields have opposite defaults:
annotationsdescribe the record they are attached to and do not propagate by default. The writer of a resulting recordMAYcopy an entry forward when the fact it records is also true of the new record.transient_annotationstravel with the write path. A consumer that emits records as a result of processing an incoming recordSHOULDpropagate the incomingtransient_annotationsonto all of the records the resulting operation emits, not a chosen subset, so that context spanning a causation chain (for example, correlating the deliveries of a workflow end to end) survives every hop without becoming part of any record's projected representation. ConsumersMAYfilter or add entries when propagating, subject to the ownership rules above; filtering is per entry and applies uniformly across the operation's resulting records.
Key collisions during propagation resolve as follows:
- When an operation processes multiple incoming records whose
transient_annotationsconflict on a key, the consumerMUSTresolve the conflict deterministically. Dropping the conflicting key is the safe default; a wrong value is worse than an absent one. - When a propagated entry collides with an entry the consumer attaches itself, the consumer's own entry wins: the author of the operation outranks inherited context.
- Propagation carries unprefixed keys across writer boundaries, where they can collide with another writer's unprefixed keys. Callers that need collision-free keys across a chain
SHOULDuse a prefix.
Propagation terminates where projection begins: resources never expose transient annotations, regardless of how many hops an entry traveled.
Choosing between the two
The writer decides at attach time:
- If the entry is a fact about the record that should remain true forever, it belongs in
annotations. - If the entry is meaningful only for the delivery and processing of the record, it belongs in
transient_annotations. Transient does not mean single-hop: an entry can propagate across an entire processing chain (see Propagation) and still never surface on a resource.
This decision is local to the writer and does not require coordination with consumers.
Consequences
- Every record type answers "where does caller metadata go?" the same way, and a reader checks exactly two fields to find it.
- Correlating a causation chain end to end works without polluting any resource's projected representation, because propagation and projection have opposite defaults.
- Prefix ownership makes cross-team key collisions a naming violation rather than a runtime surprise, and lint can enforce the syntax.
- Two doors are deliberately left open for downstream decisions: key registries (enumerating well-known keys per system) and a separate selectable field for systems that operate a selector engine. Neither changes this spec.