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
6 changes: 6 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,12 @@
"description": "Elixir skills for designing and reviewing OTP processes such as GenServers",
"source": "./plugins/trogonstack-elixir",
"category": "development"
},
{
"name": "trogonstack-protobuf",
"description": "Protobuf skills for designing messages and fields and for evolving schemas without breaking consumers",
"source": "./plugins/trogonstack-protobuf",
"category": "development"
}
]
}
5 changes: 5 additions & 0 deletions .cursor-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,11 @@
"name": "trogonstack-elixir",
"source": "plugins/trogonstack-elixir",
"description": "Elixir skills for designing and reviewing OTP processes such as GenServers"
},
{
"name": "trogonstack-protobuf",
"source": "plugins/trogonstack-protobuf",
"description": "Protobuf skills for designing messages and fields and for evolving schemas without breaking consumers"
}
]
}
3 changes: 3 additions & 0 deletions .github/release-please-config.json
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,9 @@
},
"plugins/trogonstack-elixir": {
"component": "trogonstack-elixir"
},
"plugins/trogonstack-protobuf": {
"component": "trogonstack-protobuf"
}
},
"plugins": [
Expand Down
3 changes: 2 additions & 1 deletion .github/release-please-manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
"plugins/trogonstack-eventmodeling": "0.1.2",
"plugins/trogonstack-eda": "0.1.2",
"plugins/trogonstack-product-requirements": "0.1.1",
"plugins/trogonstack-elixir": "0.1.0"
"plugins/trogonstack-elixir": "0.1.0",
"plugins/trogonstack-protobuf": "0.0.1"
}
9 changes: 9 additions & 0 deletions plugins/trogonstack-protobuf/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"name": "trogonstack-protobuf",
"description": "Protobuf skills for designing messages and fields and for evolving schemas without breaking consumers",
"version": "0.0.1",
"author": {
"name": "TrogonStack",
"url": "https://github.com/TrogonStack"
}
}
25 changes: 25 additions & 0 deletions plugins/trogonstack-protobuf/.cursor-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "trogonstack-protobuf",
"displayName": "TrogonStack Protobuf",
"description": "Protobuf skills for designing messages and fields and for evolving schemas without breaking consumers",
"version": "0.0.1",
"author": {
"name": "TrogonStack"
},
"homepage": "https://github.com/TrogonStack/agentskills/tree/main/plugins/trogonstack-protobuf",
"repository": "https://github.com/TrogonStack/agentskills",
"license": "MIT",
"keywords": [
"trogonstack",
"protobuf",
"buf",
"schema-evolution"
],
"category": "developer-tools",
"tags": [
"protobuf",
"buf",
"schema-evolution"
],
"skills": "./skills/"
}
7 changes: 7 additions & 0 deletions plugins/trogonstack-protobuf/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# trogonstack-protobuf

Protobuf skills for designing messages and fields and for evolving schemas without breaking consumers.

```bash
claude plugin install trogonstack-protobuf@trogonstack
```
19 changes: 19 additions & 0 deletions plugins/trogonstack-protobuf/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
{
"$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
"name": "trogonstack-protobuf",
"version": "0.0.1",
"description": "Protobuf skills for designing messages and fields and for evolving schemas without breaking consumers",
"author": {
"name": "TrogonStack",
"url": "https://github.com/TrogonStack"
},
"homepage": "https://github.com/TrogonStack/agentskills/tree/main/plugins/trogonstack-protobuf",
"repository": "https://github.com/TrogonStack/agentskills",
"license": "MIT",
"keywords": [
"trogonstack",
"protobuf",
"buf",
"schema-evolution"
]
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
---
name: protobuf-design-messages
description: "Design or review protobuf message and field definitions: field naming for JSON consumers, identifier and reference value objects, enums, presence, validation, and file and service layout. Use when writing a new .proto file, service, message, enum, or field, or reviewing one for naming and shape. Do not use for schema evolution, breaking-change detection, or buf lint/breaking CI setup (use protobuf-evolve-schemas)."
allowed-tools:
- AskUserQuestion
- Read
- Write
- Shell
---

# Design or Review Protobuf Messages and Fields

A `.proto` file is read by more than the compiler: ProtoJSON consumers see only field names, never types, and reviewers see only names and comments, never runtime behavior. A field, message, or enum that relies on tribal knowledge to interpret correctly is a defect, not a style nit. Apply the principles below in order; a design that fails an earlier one makes the later ones moot.

## Run buf First

Run `buf format -w` and `buf lint` (with `STANDARD`, buf's default) before anything below, and fix every finding the tool reports. Naming case, file/package/directory layout, enum zero-value and prefix conventions, service and RPC naming, and element ordering are mechanical checks a linter makes faster and more reliably than a manual pass; do not re-check them by hand. The principles below cover what `buf lint` has no way to judge: whether a name means what it says, whether a value is a primitive in disguise, and whether a shape fits the domain.

## Principles

1. **Names must survive schema-less JSON readers.** A field whose value points at something stored or owned elsewhere keeps a suffix in its name even when its type already says so.
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.
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.

## Load a Reference

| When you are... | Load |
|---|---|
| Naming a field that is an identifier, structured reference, or hierarchy position | [references/json-field-naming.md](references/json-field-naming.md) |
| Defining an identifier, or a reference to another resource | [references/identifiers-and-references.md](references/identifiers-and-references.md) |
| Defining a pointer to content stored out of line | [references/claim-checks.md](references/claim-checks.md) |
| Defining a quantity, a monetary amount, or an instant in time | [references/units-money-and-time.md](references/units-money-and-time.md) |
| Defining an enum, naming its values, or designing a preset/option field | [references/enums-and-presets.md](references/enums-and-presets.md) |
| Deciding whether a field should be required, or writing a `buf.validate` constraint | [references/presence-and-validation.md](references/presence-and-validation.md) |
| Designing commands, events, state, or faults for an event-sourced resource | [references/event-sourced-contracts.md](references/event-sourced-contracts.md) |
| A field, message, or package name echoes a term used elsewhere for a different concept | [references/naming-collisions.md](references/naming-collisions.md) |
| Creating a `.proto` file, adding a `service`, or naming RPC request and response messages | [references/file-layout.md](references/file-layout.md) |
| Wanting to see every principle applied in one complete `.proto` package | [references/example-project-schema.md](references/example-project-schema.md) |
| Reviewing or auditing an existing schema | [references/review-checklist.md](references/review-checklist.md) |
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Claim Checks for Content Stored Out of Line

Use this reference when defining a pointer to content stored out of line.

## A Locator and a Digest, Together

Content stored out of line (blob storage, an artifact store, a large payload moved out of the message body) is the [claim check pattern](https://www.enterpriseintegrationpatterns.com/ClaimCheck.html): the message carries a claim, not the content. The claim is only useful if it says both where the content is and what it should hash to. Keep both fields in one value object; a locator in one message and a digest in a sibling field invites the two being paired wrong after an edit or a partial copy.

```protobuf
// Avoid: locator and digest live in different messages, so nothing stops
// a locator from one artifact pairing with a digest from another.
message StartRestore {
string storage_locator = 1;
}

message RestoreManifest {
string expected_digest = 1;
}

// Prefer: the claim check is one value object with both fields.
message Digest {
string algorithm = 1; // e.g. "sha256"
bytes value = 2;
}

message ClaimCheck {
string storage_locator = 1;
Digest digest = 2;
}

message StartRestore {
ClaimCheck claim_check = 1;
}
```

## Review Questions

- Does any out-of-line content pointer carry a locator without the digest it is verified against, or vice versa, in separate messages?
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
# Enums: Exhaustive Choice, Not a Growable Boolean

Use this reference when defining an enum, naming its values, or designing a preset/option field such as an access or visibility level.

## Zero Value Is Unspecified, and Unspecified Is a Rejection

The zero value of every enum must end in `_UNSPECIFIED` (buf lint rule `ENUM_ZERO_VALUE_SUFFIX`). That value exists because every enum field that is never explicitly set reads as its zero value on the wire; naming it `_UNSPECIFIED` makes that silent default visible instead of letting it masquerade as a real choice.

Enum value names are prefixed with the enum's name (buf lint rule `ENUM_VALUE_PREFIX`), which also keeps C++ scoping, where enum value names share their enclosing scope, from colliding across enums in the same file:

```protobuf
// Avoid: unprefixed values collide with another enum's values in the same package,
// and the zero value reads as a real default.
enum Visibility {
PRIVATE = 0;
PUBLIC = 1;
}

// Prefer: prefixed values, explicit unspecified zero value.
enum Visibility {
VISIBILITY_UNSPECIFIED = 0;
VISIBILITY_PRIVATE = 1;
VISIBILITY_PUBLIC = 2;
}
```

## An Omitted Choice Must Not Silently Widen Behavior

`_UNSPECIFIED` is a value the wire format can produce without anyone intending it: an old client built before the field existed, a caller who forgot to set it, a bug. The service must reject that value rather than resolve it to a convenient default, especially when the convenient default is the permissive one.

Consider an access preset on a resource:

```protobuf
enum AccessPreset {
ACCESS_PRESET_UNSPECIFIED = 0;
ACCESS_PRESET_PRIVATE = 1;
ACCESS_PRESET_ORGANIZATION = 2;
ACCESS_PRESET_PUBLIC = 3;
}

message CreateProject {
AccessPreset access_preset = 1;
}
```

If the service treats `ACCESS_PRESET_UNSPECIFIED` as `ACCESS_PRESET_PUBLIC` "so something reasonable happens," an old client, a client with a bug, or a caller who genuinely forgot the field just made the resource public by omission. The creator never chose that, and the resource the creator meant to keep private is now exposed. The only safe reading of `UNSPECIFIED` for a field like this is "reject the request and ask the caller to choose," never "pick the widest option on their behalf."

The rule holds even when a narrow default looks harmless: `UNSPECIFIED` is rejected, never resolved. A default the service picks is a default nobody can see in the schema, and the next reader cannot tell a caller who chose `PRIVATE` from one who sent nothing. If a field genuinely has a safe default, make it an explicit value the caller sends, and let the client library fill it in where a human would otherwise forget.

```cpp
// Avoid: service code that treats UNSPECIFIED as a convenient permissive default.
switch (request.access_preset()) {
case AccessPreset::ACCESS_PRESET_UNSPECIFIED:
case AccessPreset::ACCESS_PRESET_PUBLIC:
return CreateWithPreset(AccessPreset::ACCESS_PRESET_PUBLIC);
// ...
}

// Prefer: reject UNSPECIFIED explicitly; see references/presence-and-validation.md
// for enforcing this at the schema layer with protovalidate.
if (request.access_preset() == AccessPreset::ACCESS_PRESET_UNSPECIFIED) {
return InvalidArgumentError("access_preset is required");
}
```

## Prefer an Enum or a `oneof` Over a Boolean That Will Grow

A boolean field locks a concept into exactly two states forever; adding a third state means adding a second boolean and reconciling the combinations it creates. If there is any chance the concept has more than two states eventually, model it as an enum from the start:

```protobuf
// Avoid: a boolean that is one feature request away from needing a third state.
bool is_archived = 1;

// Prefer: an enum that can grow.
enum ProjectState {
PROJECT_STATE_UNSPECIFIED = 0;
PROJECT_STATE_ACTIVE = 1;
PROJECT_STATE_ARCHIVED = 2;
}
```

When two or more fields are mutually exclusive ways of saying the same thing (a result is either a success payload or a fault, never both), use `oneof` instead of a pair of optional fields plus a discriminator boolean; see [references/event-sourced-contracts.md](event-sourced-contracts.md) for a `oneof` used to model a fault.

## Review Questions

(Zero-value naming and value prefixing are checked by `buf lint`'s `ENUM_ZERO_VALUE_SUFFIX`/`ENUM_VALUE_PREFIX`; run the tool instead of eyeballing a diff.)

- Is there service code anywhere that treats an enum's `UNSPECIFIED` value as a usable default rather than rejecting it?
- Would resolving `UNSPECIFIED` to a default ever widen access, cost, or scope beyond what the caller explicitly chose?
- Is there a boolean field describing something that could plausibly need a third state?
Loading
Loading