-
Notifications
You must be signed in to change notification settings - Fork 3
chore(adr): ratify the credential platform decisions before implementation #530
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
||
| 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) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.