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
196 changes: 196 additions & 0 deletions docs/adr/0045-event-sourced-service-module-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
---
number: "0045"
slug: event-sourced-service-module-layout
status: accepted
date: 2026-07-08
---

# ADR#0045: Aggregate-Oriented Module Layout for Event-Sourced Services

## Context

Several first-party services implement an event-sourced model: typed commands
decide events, an evolve function folds events into state, snapshots compact that
state, and read-side processors project the stream into runtime effects.
`trogon-scheduler` and `trogon-gateway` both follow this shape.

[ADR#0002](./0002-rust-crate-boundaries.md) governs package boundaries but deliberately stops short of prescribing
intra-package module structure. `rsworkspace/crates/AGENTS.md` governs value
objects, boundary types, and errors, but describes a flat crate-root layout that
fits value-object and library crates, not an event-sourced service.

With no rule, two failure modes appeared. First, code was organized by layer at
the crate root: a top-level `commands/` module in a crate that hosts more than
one stream reads as "the crate's commands," when the commands actually belong to
one specific stream among several. Second, aggregates were named after the
mechanism rather than the domain: `CredentialLifecycle*` names describe that the
type participates in an event-sourced lifecycle, which every event-sourced
aggregate does by definition, so the qualifier carries no information and
compensates for the fact that the opposing concern (the secret material) was
never named on the same axis.

The repository needs one rule for how event-sourced code is organized and named,
and a named reference implementation.

## Decision

Organize event-sourced code by aggregate, not by layer. Name aggregates as bare
domain nouns. `trogon-scheduler` is the reference implementation.

### Organize by stream

The unit of organization is the stream, not a technical layer. Stream,
aggregate, and workflow name that one unit from three angles: the aggregate is
the consistency boundary that decides commands, the stream is that aggregate's
event log, and the workflow is what the log captures over time. One aggregate,
one stream, one module; wherever this ADR says "stream" or "aggregate" it
means this same unit, never two different module boundaries. There is one
module per stream, named for that stream.
`commands` (with its nested `state`, `snapshot`, and `domain` submodules) and
the read-side `processor` are subdivisions inside a stream, never top-level
buckets that imply the whole crate is a single command model.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

A stream module contains:

- `commands/`: the write side. One file per command decider, named after the
command; each file holds the command struct and its `Decider` implementation.
The decider's `Event` and `State` associated types are the generated proto
types used directly (see "Proto types are the event and state" below), not
hand-written domain enums. The write-side submodules nest inside `commands/`:
- `commands/state`: `initial_state`, `evolve`, and the decide-time and
evolve-time validators, operating on the proto state type.
- `commands/snapshot`: the snapshot policy only, for example the snapshot
frequency. It does not contain the snapshot codec.
- `commands/domain`: the aggregate's value objects used as command inputs,
one type per file. The event and state shapes are proto, so this module
holds value objects, not event or state definitions.
- `processor` (or a named projection): the read model that consumes the stream.
A processor's own rebuildable checkpoint store nests under that processor and
is its adapter boundary: the store owns the persistence SDK calls, and
projection logic above it stays SDK-free.
- The aggregate's persistence and command handler: the event store, stream and
subject configuration, and the handler that executes commands against the
store. These belong to the aggregate and live inside its module, next to the
commands they serve. They are not hoisted to the crate root.

A crate that owns exactly one aggregate places that aggregate at the crate root,
so a root-level `commands/` reads correctly (`trogon-scheduler`: the crate is the
schedules aggregate). A crate that hosts multiple aggregates or bounded contexts
nests each aggregate under its own noun module (`trogon-gateway`: the credential
aggregate lives under `credential/`, separate from the webhook-ingress sources
and the secret backends).

### Name a stream for its workflow, as a noun

A stream is not always a static entity. More often it captures a workflow, a
process that unfolds over time. Name the stream for that workflow, expressed as a
noun: a workflow made noun. The name states which workflow the stream is, never
that it happens to be event-sourced.

Do not qualify a stream with mechanism words such as `Lifecycle`, `Manager`,
`Service`, or `Handler`. Every event-sourced stream has a lifecycle and is
handled by something, so those words name the machinery, not the workflow, and
carry no information. `Lifecycle` is the clearest offender: it restates the
pattern.

Name opposing concerns on the same axis instead of qualifying one of them. A
credential has two: the workflow that provisions and maintains it over time
(request, activate, rotate, revoke), and its secret material. Name them
`Credential` (the stream, event-sourced, holds no secret bytes) and `Secret`
(the material, held by the secret store). Do not name the first
`CredentialLifecycle` to distinguish it from the second; naming the second
`Secret` already does that.

### Value objects

Domain value objects for an aggregate live under that aggregate's
`commands/domain` module, one file per type, with the construction and error
rules from `rsworkspace/crates/AGENTS.md`. The flat `src/{type}.rs` placement in
the crate conventions applies to value-object and library crates that own no
aggregate. The read model depending on the aggregate's `commands/domain` is
expected, not a layering violation.

### Proto types are the event and state; their codec lives in the proto crate

The decider's `Event` and `State` are the generated proto messages, used
directly (`type Event = v1::CredentialEvent; type State = state_v1::CredentialStateSnapshot`).
Do not wrap them in parallel hand-written domain enums with manual proto-to-domain
conversion; that duplicates the schema ([ADR#0009](./0009-protocol-buffers-wire-contracts.md)) and drags the codec into the
consuming crate.

The event and snapshot codec, the `EventEncode`, `EventDecode`, `EventType`,
`SnapshotType`, and `SnapshotPayload*` implementations, lives in `trogonai-proto`,
implemented on the proto types. This is forced by the orphan rule: both the trait
(`trogon-decider-runtime`) and the type (generated proto) are foreign to the
consuming crate, so the impl must live in the crate that owns the proto type.

Register a per-domain cargo feature in `trogonai-proto` for each event-sourced
domain (`schedules`, `gateway`), pulling in `trogon-decider-runtime` and any codec
dependencies, and gating that domain's hand-written support module (codec, payload
error types, oneof re-exports). The consuming crate enables that feature and uses
the proto types as its decider types. `trogon-scheduler` with the `schedules`
feature is the reference; its `commands/snapshot.rs` is only the policy const
because the codec lives in `trogonai-proto`.

### Domain stays free of infrastructure

Decision and projection logic in `commands` and `processor` is free of
transport and persistence SDKs. What converts at the boundary per
[ADR#0009](./0009-protocol-buffers-wire-contracts.md) is the transport and
persistence envelope: adapters turn message frames, KV entries, and wire bytes
into the proto event and state messages and the aggregate's value objects, and
back. The decider's event and state values stay the generated proto types
through that conversion; the boundary never reintroduces the parallel domain
enums rejected above. Infrastructure adapters (NATS/JetStream stores, stream and
subject configuration, KV stores) are thin and live inside the module
that owns them, not scattered at the crate root: the event store and command
handler next to the commands they serve, a processor's checkpoint store nested
under that processor.

## Design Rules

- One command decider per file.
- The decider `Event`/`State` are proto types used directly; do not add parallel
domain enums or hand-written proto conversion for them ([ADR#0009](./0009-protocol-buffers-wire-contracts.md)).
- The event and snapshot codec and the `trogon-decider-runtime` trait impls live
in `trogonai-proto` on the proto types, behind a registered per-domain feature
that pulls in `trogon-decider-runtime`. The consuming crate's
`commands/snapshot` module holds only the snapshot policy.
- Keep the fold (`evolve`) separate from the snapshot policy.
- Name a stream for the workflow it represents, expressed as a noun (a workflow
made noun), not for the event-sourcing mechanism. Reject `Lifecycle`,
`Manager`, `Service`, `Info`, and similar mechanism or filler qualifiers in
stream, event, state, and command type names.
- Persisted identifiers follow the aggregate noun: proto message names, stream
names, subjects, and KV keys. Because a persisted message's package path or
fully-qualified name is embedded in storage keys ([ADR#0009](./0009-protocol-buffers-wire-contracts.md)), renaming an
aggregate is a migration. Do it before the contract ships; treat it as
storage-breaking afterward.
- Value objects follow `rsworkspace/crates/AGENTS.md`, located under the
aggregate's `commands/domain`.

## Consequences

- Event-sourced code is grouped by the thing it models, so a reader sees one
aggregate's full write and read model in one place, and a multi-aggregate crate
does not imply that one aggregate speaks for the whole crate.
- Aggregate names carry domain meaning instead of restating the pattern. The
credential aggregate is `Credential`; the secret material is `Secret`.
- The value-object placement contradiction between
`rsworkspace/crates/AGENTS.md` and real aggregate crates is resolved.
- `rsworkspace/crates/AGENTS.md` gains a pointer to this ADR and to
`trogon-scheduler` as the reference.
- `trogon-gateway` migrates: the credential aggregate consolidates under
`credential/` with `lifecycle` dropped, and its event and snapshot codec moves
out of `credential/commands/` into `trogonai-proto` behind the `gateway`
feature (which gains `trogon-decider-runtime`), so the deciders use the proto
`CredentialEvent`/`CredentialStateSnapshot` types directly and the hand-written
domain-enum conversion is removed. Done before the contract ships.

## References

- [ADR#0002: Rust Crate Boundaries](./0002-rust-crate-boundaries.md)
- [ADR#0009: Protocol Buffers Wire Contracts](./0009-protocol-buffers-wire-contracts.md)
- `rsworkspace/crates/AGENTS.md`
- `rsworkspace/crates/trogonai-proto` (per-domain feature + codec convention)
- `rsworkspace/crates/trogon-scheduler` (reference implementation)
126 changes: 126 additions & 0 deletions docs/adr/0046-project-anchored-resource-hierarchy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
---
number: "0046"
slug: project-anchored-resource-hierarchy
status: accepted
date: 2026-08-05
---

# ADR#0046: Project-Anchored Resource Hierarchy for the Credential Platform

## Context

The credential vault and API key platform needs one canonical owner boundary.
The shipped gateway slice already bakes `CredentialOwnerId` into OpenBao paths
(`trogonai/{owner_id}/credentials/{credential_id}`), generated credential ids
(`openbao:{owner}:{scope}:{kind}`), event stream routing, and the credential
state protos. Whatever "owner" means, changing its meaning later migrates
every one of those surfaces at once, so the boundary must be chosen before the
public management API or the broader domain model hardens around it.

The candidates were workspace, organization, project, tenant, or user. The
three hyperscalers embody three answers, and their histories matter more than
their marketing:

- AWS hardened the wall it happened to have. The account predates IAM; the
ecosystem converged on many-accounts-per-company and AWS formalized it
after the fact. Isolation is structural and strong, and every interior
structure is ceremony bolted around a retail-era boundary.
- Azure accreted a layer per business era: the tenant is the enterprise
identity directory, the subscription is a procurement artifact, resource
groups patched subscriptions for deployment lifecycle, management groups
patched governance above them.
- GCP designed top-down. The project was the API-console unit from the
beginning (billing attachment, quota, IAM anchor), and when organizations
and folders arrived years later they slotted in above without renaming a
single resource, because resource names had always anchored at the
project: `projects/{project}/secrets/{secret}/versions/{version}`.

That last property is the decisive one. GCP resource names anchor at the most
stable container and never embed the hierarchy above it, so reorganizing
companies, teams, or billing never rewrites a stored path. GCP Secret
Manager's model (secret plus per-version enabled/disabled/destroyed states)
is also nearly isomorphic to the credential version lifecycle this platform
already ships, which makes its naming grammar a proven reference rather than
an invention.

Isolation strength does not force the choice. A hard AWS-style wall and a
GCP-style connected hierarchy converge in achievable capability; they differ
in default posture and in which misconfiguration class bites. Every rung of
the isolation ladder (scoped policies on shared infrastructure, per-owner
OpenBao namespaces and keys, dedicated cells) lives beneath the resource
model and changes no name, path, or stream.

## Decision

### 1. Organization over project; the project is the owner boundary

The hierarchy is two levels: organization, then project. The project is the
unit that owns credential vaults, credentials, integrations, API keyspaces,
and quotas. `CredentialOwnerId` in the shipped slice reads as a project id,
and the broader owner value object is a project id.

### 2. Names anchor at the project and never embed the organization

The project id is an identifier in the [ADR#0040](./0040-contract-field-vocabulary.md)
sense: rigid, opaque, minted once, never reused. Human-facing naming is a
`display_name` on the project record, never part of a path. Resource names,
OpenBao paths, event stream routing, and generated credential ids contain the
project id and nothing above it, so re-parenting a project to a different
organization is an IAM and billing event, not a storage migration.

### 3. Public resource names are parent-scoped

The public management API adopts AIP-style parent-scoped resource names rooted
at the project: `projects/{project}/credential-vaults/{vault}`,
`projects/{project}/credentials/{credential}`, and so on. Flat, unparented
names such as `/v1/credentials` are superseded. The parent is also an
authorization statement: admission derives the expected project from the
authenticated caller context
([ADR#0050](./0050-signed-first-caller-authentication.md),
[ADR#0051](./0051-fully-bound-request-signing.md)) and rejects a request whose
`{project}` does not match it.

### 4. Environments are attributes, not hierarchy levels

Environment (production, preview, development) is a field on vaults and
credentials, the way Vercel scopes environment variables, not a container in
the hierarchy.

### 5. Isolation is deployment posture beneath the model

Tenant isolation is implemented under the unchanged resource model, in
rungs: scoped OpenBao policies on shared infrastructure first, per-project or
per-organization OpenBao namespaces, mounts, and encryption keys when a
customer tier demands it, dedicated cells at the top. GCP's own guardrail
retrofits (organization policy constraints, deny policies, service
perimeters) are the reference list for the constraint plane this platform
will eventually place above per-project grants.

### 6. Deferred layers

Organizations ship later as a pure IAM and billing plane above projects.
Folder-style nesting is deferred until enterprise demand exists; the naming
rule in section 2 guarantees it can be added without touching stored names.

## Consequences

- The shipped OpenBao path convention `trogonai/{owner_id}/credentials/{credential_id}`
is ratified as-is, with owner id understood as project id, so no stored path
migrates.
- The tenant value that [ADR#0027](./0027-decider-multi-tenancy-primitive.md)
threads through decider stream and snapshot resolution carries the project
id for credential aggregates.
- Domain work names the owner value object as a project id rather than
inventing a parallel workspace concept; workspace-shaped fields collapse
into the project.
- Cross-project credential moves are disallowed, matching GCP Secret
Manager; a credential is born in a project and dies there.

## References

- [ADR#0027: Tenant Value Object for Decider Stream and Snapshot Resolution](./0027-decider-multi-tenancy-primitive.md)
- [ADR#0040: Contract Field Vocabulary: Identifiers, Handles, and Display Labels](./0040-contract-field-vocabulary.md)
- [ADR#0050: Signed Proof-of-Possession as the Strongly Recommended Caller Authentication](./0050-signed-first-caller-authentication.md)
- [ADR#0051: Fully Bound Per-Request Signing Contract](./0051-fully-bound-request-signing.md)
- Google API Improvement Proposals, resource-oriented design (aip.dev)
- Google Cloud Secret Manager resource model and Vercel environment scoping
Loading