Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Run `buf format -w` and `buf lint` (with `STANDARD`, buf's default) before anyth
2. **No primitive obsession.** Identifiers, references, and out-of-line content pointers are value objects, not bare strings; units, money, and instants follow the ecosystem's naming patterns.
3. **Enums encode an exhaustive choice, not a growable boolean.** The zero value is always `<ENUM>_UNSPECIFIED`, code must reject it, and a boolean that might need a third state should be an enum instead.
4. **Know what the schema can and cannot guarantee.** Editions presence and `protovalidate` constraints are schema-level tools with real limits; some invariants need live state and belong in the service.
5. **Event-sourced contracts separate commands, events, state, and faults.** Commands are imperative, events are past tense, and an aggregate never gets an event for a fact it does not own.
5. **Event-sourced contracts separate commands, events, state, and faults.** Commands are imperative, events are past tense, provenance (who caused it, when each step happened) is platform metadata on every event's envelope, guaranteed by the infrastructure, occurrence time is event context, only domain time stays in the payload, and an aggregate never gets an event for a fact it does not own.
6. **Rename early when words collide with product vocabulary.** The cheapest moment to fix a name is before anything generates code from it.
7. **A file is named after what it defines.** One top-level definition per file in `lower_snake_case`, a service in `<name>_service.proto` named `<Name>Service`, and its own `<Method>Request` and `<Method>Response` for every RPC.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Use this reference when designing commands, events, state, or faults for an even
## Contents

- Name by Tense
- Who and When Belong to the Envelope, Not the Payload
- Record Creation-Time Facts on the Creation Event, Even When Another System Owns What Happens Later
- Do Not Add an Event for a Fact the Aggregate Does Not Own
- State and Faults
Expand All @@ -27,18 +28,72 @@ message ProjectRenamed { ... }
message ProjectArchived { ... }
```

## Who and When Belong to the Envelope, Not the Payload

Who caused an event and when each step of its life happened are provenance. They are the same question for every event in the system, so they belong to platform metadata the infrastructure attaches to every event's envelope, not to individual contracts. When the infrastructure records them, keep them out of the payload. A `created_by`, `requested_by`, `creator`, `principal_id`, or `recorded_at` on a command or event then duplicates the envelope at best, and at worst lets the caller decide whose name goes on the record.

Commands are where this goes wrong first. The issuing principal comes from authentication, never from a field the caller fills in; a `creator` on `CreateProject` lets anyone claim to be anyone. Copying it onto the event then turns that claim into a recorded fact.

```protobuf
// Avoid: provenance copied into the payload, and on the command, supplied by the caller.
message CreateProject {
string name = 1;
UserId created_by = 2;
}

message ProjectArchived {
ProjectId project_id = 1;
UserId archived_by = 2;
google.protobuf.Timestamp archived_at = 3;
}

// Prefer: the payload carries only domain facts; who and when come from the envelope.
message CreateProject {
string name = 1;
}

message ProjectArchived {
ProjectId project_id = 1;
}
```

### The Infrastructure Has to Make It True

Moving provenance to the envelope is only correct when the platform guarantees it, so confirm each of these before removing a payload field, and if any is missing, fix the infrastructure first instead of dropping the only record:

- The platform, not the caller or the domain logic, records the authenticated actor on every event, across every contract, with no opt-in. System processes (schedulers, processors) act under identities of their own, so no event lacks an actor.
- The platform records a time for each step that matters, not only the write to storage: when the request was accepted, when the event was produced, and when it was first recorded. Each is stamped by the part of the platform that observed that step.
- Both survive everything that moves events: replay, re-appending into another stream, restore from backup or archive, mirroring and replication. A store's own message timestamp is usually the time of that particular write, so re-appending an event elsewhere resets it unless the original is carried forward as metadata.
- Consumers that need these values (an access-control system granting the creator membership, an audit view) have a supported way to read them.

Where these values live and how they are encoded is specific to each platform and each store, so this skill names no keys. The platform has to document, in one place, which envelope values it guarantees, where each lives, and what is still missing. Read that before dropping a payload field, and if it does not exist, that is the first gap.

Until those hold, a payload field is the honest place for the value; document it as a stopgap tied to the missing guarantee. If a pure decider needs the value to emit it, the trusted ingress that authenticated the caller stamps it onto the command, overwriting anything the client sent, the same way a host stamps the time; the client's own claim is never trusted.

### Recorded Time Is Not Domain Time

Times about the event itself and times inside the business fact are different values, and only the first kind is provenance:

- **Envelope times**: when the request was accepted, when the event was produced, and when it was first recorded. Assigned by the infrastructure, never by the caller.
- **Occurrence time**: when the producer says the event happened (`occurred_at`), which differs from recorded time for a device that synced hours later, a backfill or import of historical facts, or a webhook carrying the external system's own timestamp. Every event has one, so it is event context in the envelope, kept distinct from recorded time, and recorded as the producer's claim rather than a platform observation.
- **Domain time**: a time that is part of the business fact itself, such as when a scheduled occurrence was due, when a policy change takes effect, or when a measurement was taken. This belongs in the payload.

Name a domain time for what it means (`due_at`, `effective_at`, `measured_at`), never with a generic `occurred_at` or `created_at` that a reader will mistake for an envelope or occurrence time. If an envelope or occurrence time is always the right answer for a use case, there is no domain time to model. See the EDA skill's event record layers for the same split between record metadata, event context, and payload.

A decider's `decide` is pure and has no clock. When a decision needs domain time, it arrives in the command from a source the system trusts (a timer, a host-stamped value, a validated external timestamp), never from a client's unchecked `now`.

An actor field belongs in the payload by the same test: only when it names someone other than the issuer, such as an approver named in a command a system process issues on their behalf. If the value is always the actor that caused the event or one of the envelope times, it is provenance. No linter catches this; it has to be checked in review.

## Record Creation-Time Facts on the Creation Event, Even When Another System Owns What Happens Later

A resource's creation event is the one place certain facts can ever be recorded as "true at the moment this came into existence": who created it, where it was placed at birth, what access level it started with. Even if another system owns moves, renames, or access changes afterward, the creation event still owns the initial values, because nothing else will ever again be in a position to say what they were at creation time.
A resource's creation event is the one place certain facts can ever be recorded as "true at the moment this came into existence": where it was placed at birth, what access level it started with. (Who created it and when are provenance, recorded in the envelope as above.) Even if another system owns moves, renames, or access changes afterward, the creation event still owns the initial values, because nothing else will ever again be in a position to say what they were at creation time.

```protobuf
message ProjectCreated {
ProjectId project_id = 1;
string name = 2;
UserId created_by = 3;
NodeId parent = 4; // where it was placed at birth
AccessPreset initial_access_preset = 5; // access choice at creation, even if access changes later move elsewhere
google.protobuf.Timestamp created_at = 6;
NodeId parent = 3; // where it was placed at birth
AccessPreset initial_access_preset = 4; // access choice at creation, even if access changes later move elsewhere
}
```

Expand Down Expand Up @@ -115,6 +170,9 @@ ProjectId project_id = 1;
## Review Questions

- Are commands named imperatively and events named in the past tense?
- Does any command or event copy who caused it or when it was accepted, produced, or recorded into the payload, while the platform already guarantees those on every event's envelope? If the infrastructure does not guarantee them yet, is that the gap being fixed?
- Does any command let the caller supply its own identity?
- Is every payload time a domain time named for its meaning, distinct from the envelope times?
- Does the creation event carry every fact that is only knowable at creation time, even if later changes to those facts are owned elsewhere?
- Does any event represent a fact the aggregate does not own, duplicating another system's source of truth?
- Are faults modeled as a `oneof` of specific types rather than a generic error string or code?
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,18 +15,13 @@ edition = "2023";
package example.projects.v1alpha1;

import "buf/validate/validate.proto";
import "google/protobuf/timestamp.proto";

// --- Identifiers and references (principle 2: no primitive obsession) ---

message ProjectId {
string value = 1 [(buf.validate.field).string.min_len = 1];
}

message UserId {
string value = 1 [(buf.validate.field).string.min_len = 1];
}

// A hierarchy position. Bare `parent` names a role (principle 1's exception):
// a position can point at a node of any kind, which is the point of the field.
message NodeId {
Expand Down Expand Up @@ -76,8 +71,10 @@ message CreateProject {
string name = 1 [(buf.validate.field).string.min_len = 1];
NodeId parent = 2;
AccessPreset access_preset = 3 [
(buf.validate.field).enum.defined_only = true,
(buf.validate.field).enum = {not_in: [0]}
(buf.validate.field).enum = {
defined_only: true
not_in: [0]
}
];
Money budget = 4;
}
Expand All @@ -96,24 +93,21 @@ message ArchiveProject {
message ProjectCreated {
ProjectId project_id = 1;
string name = 2;
UserId created_by = 3;
// Placement and access at birth. Later moves and access changes are owned
// by other systems; this event only records the facts true at creation.
NodeId parent = 4;
AccessPreset initial_access_preset = 5;
Money initial_budget = 6;
google.protobuf.Timestamp created_at = 7;
// Who created it and when live in the event envelope, not here.
NodeId parent = 3;
AccessPreset initial_access_preset = 4;
Money initial_budget = 5;
}

message ProjectRenamed {
ProjectId project_id = 1;
string name = 2;
google.protobuf.Timestamp renamed_at = 3;
}

message ProjectArchived {
ProjectId project_id = 1;
google.protobuf.Timestamp archived_at = 2;
}

// Deliberately absent: ProjectMoved, ProjectAccessChanged. Placement and
Expand Down Expand Up @@ -147,12 +141,13 @@ message RenameProjectFault {

| Choice | Principle |
|---|---|
| `ProjectId`, `UserId`, `NodeId` as wrapper messages | No primitive obsession |
| `ProjectId`, `NodeId` as wrapper messages | No primitive obsession |
| `NodeId parent` bare, no `_id` suffix | `parent` names hierarchy position, never qualified |
| `Money` with `amount_micros` + `currency_code` | Units and currency travel together, never a bare float; the schema checks the code's shape, the service checks it is a real ISO 4217 code |
| `AccessPreset` zero value rejected via `buf.validate.field.enum` | An omitted choice cannot silently widen access |
| `CreateProject.name` marked `required` via `string.min_len = 1` | Schema-checkable invariant expressed portably |
| `ProjectCreated` carries `parent` and `initial_access_preset` | Creation-time facts recorded even though later changes are owned elsewhere |
| No `created_by`, `created_at`, or `archived_at` on commands or events | Who issued it and when it was recorded are provenance the event envelope carries; the project has no separate domain time |
| `Project` state omits `parent` and access | Folded state holds only facts the aggregate's own events keep current |
| No `ProjectMoved` or `ProjectAccessChanged` event | The aggregate does not emit events for facts it does not own |
| `RenameProjectFault` as a `oneof` | Specific, matchable fault types instead of a generic error |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ Each remaining item is a symptom of a principle `buf lint` cannot check, not a r
Skip this section for plain CRUD/RPC schemas.

- [ ] Commands are imperative; events are past tense
- [ ] No command lets the caller supply its own identity
- [ ] Provenance (the actor that caused the event, the envelope times) stays out of payloads where the platform guarantees it on every event's envelope, across replay, re-append, and restore
- [ ] Payload times are domain times named for their meaning, never a copy of an envelope time or the producer-reported occurrence time
- [ ] The creation event carries every fact only knowable at creation time
- [ ] No event duplicates a fact owned by another system
- [ ] Faults are a `oneof` of specific types, not a generic error string
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ int64 created = 1;
google.protobuf.Timestamp created_at = 1;
```

On an event-sourced command or event, the times the platform observed it (accepted, produced, recorded) are provenance for the envelope, the producer-reported occurrence time is event context in the envelope, and only a time that is part of the business fact (due, effective, measured) is a payload field named for its meaning; see [event-sourced-contracts.md](event-sourced-contracts.md).

## Review Questions

- Does any numeric field omit its unit from the name?
Expand Down
Loading