Skip to content

Skills · solution-architect

event-contract-draft

Draft the contract for an event or message interface (Kafka topics, SNS/SQS, EventBridge, Pub/Sub, RabbitMQ, webhooks) as an AsyncAPI 3.0 document plus a short decisions page — past-tense event names, a required envelope with event id for deduplication, the partition key and the ordering it does and doesn't give, at-least-once delivery and how consumers stay idempotent, data minimisation with PII marked, additive-only versioning with dated deprecation, and retention, replay and dead-letter handling. Use when the user wants to define events, design a Kafka topic or message schema, write an AsyncAPI spec, agree what a producer publishes and what consumers must do, or version an event safely.

Pack solution-architectTier standardJob Design the systemv0.1.3

npx skilldrop-cli install event-contract-draft
Other ways to install

With the sibling skills it hands off to:

npx skilldrop-cli install event-contract-draft --with-related

The whole pack it belongs to:

npx skilldrop-cli install --pack solution-architect

By hand, from a clone of the repo:

cp -R packs/solution-architect/skills/event-contract-draft ~/.claude/skills/

In Claude Code, with the solution-architect plugin installed, invoke it as:

/solution-architect:event-contract-draft

Try it

A realistic prompt from the skill's acceptance evals:

We're moving fulfilment, loyalty and analytics off polling our order API and onto Kafka events. Our brief is in order-events-brief.md. Can you draft the event contract so the three teams can start building?

What a good result looks like

Example output

Worked example: order service events with four traps · the skill's own worked example, source

Input

We're moving fulfilment, loyalty and analytics off polling our order API and onto Kafka events. Our brief is in order-events-brief.md. Can you draft the event contract so the three teams can start building?

The brief (in ../evals/files/1/order-events-brief.md) asks for a CreateShipment event, an analytics feed "in one single global order", a customerEmail on every event "just in case", and an in-place rename of total to an integer totalAmount on the live OrderPlaced, which fulfilment consumes in production. Retention is 7 days; nobody owns failed messages yet.

Output

What the contract decides

  • CreateShipment becomes OrderPaid. The order service publishes the fact; the warehouse decides to pick when it sees it. A command on a shared topic would make the order service own the warehouse's workflow.
  • No single global order across all orders. Kafka orders messages only within a partition; one partition for everything means one consumer at a time, and retries and dead-letter redrives reorder it anyway. Instead, every event for one order arrives in order (the topic is keyed by order id), and each event carries occurredAt and orderRevision so analytics can sort across orders when it reads.
  • customerEmail is dropped. None of the three consumers uses it, and on Kafka it would sit in every consumer's copy for the whole retention window. The delivery address stays, on OrderPaid only, marked as personal data that fulfilment needs.
  • total is not changed in place. Fulfilment reads OrderPlaced in production and would break on deploy. The new events go on a new topic, orders.order-lifecycle.v1: OrderPlaced version 2 (with totalAmount in pence), OrderPaid and OrderCancelled. The existing orders topic keeps publishing today's OrderPlaced unchanged until a date agreed with fulfilment.

Decisions

#DecisionChoiceWhy
1Event namesOrderPlaced, OrderPaid, OrderCancelledFacts in the order domain; replaces CreateShipment
2Topic and keyone topic, orders.order-lifecycle.v1, keyed by orderIdPaid and cancelled for one order must not race; one key means one partition, in order
3Orderingper order only; no order across orders, no global orderKafka's guarantee is per partition
4Producer publishingeach order's events published in commit order, from an outbox written in the same transactionWithout it the per-order promise in 2 is false
5Payload styleOrderPlaced and OrderPaid carry the order; OrderCancelled carries ids and who cancelledFulfilment picks and loyalty awards from OrderPaid alone; cancelling needs only the id
6EnvelopeeventId, eventType, eventVersion, occurredAt (UTC, RFC 3339), producer, correlationId, causationIdDedupe, routing, tracing; today's placedAt has no time zone
7MoneytotalAmount as integer pence plus currencyToday's 42.50 is a decimal float
8Personal datacustomerEmail dropped; deliveryAddress on OrderPaid only, markedNo consumer for the email; the address only once there is something to ship
9The total changeOrderPlaced v2 on the new topic; v1 on orders unchanged until sunsetA rename plus a type change is breaking; both publish during the window
10Deliveryat-least-onceKafka redelivers on producer retries, rebalances and replays
11Retention and replay7 days; consumer groups can reset offsets within itPlatform default from the brief
12Dead lettereach consumer group parks a message after 5 failed attempts [assumption] on its own DLQOwner open; see below

What consumers must do

  1. Deduplicate on eventId. Fulfilment: a second OrderPaid for one order never starts a second pick. Loyalty: award points keyed on the order id, so a repeat is a no-op.
  2. Apply an event only if its orderRevision is higher than the last one applied for that order; drop it otherwise.
  3. Ignore fields and event types you don't recognise.
  4. Never rely on order across orders. Analytics sorts by occurredAt, then orderRevision.
  5. Park a message that fails 5 times on your DLQ, and watch it.
  6. Fulfilment: move from orders to orders.order-lifecycle.v1 before the agreed date.

The AsyncAPI document is saved as asyncapi.json (here: order-service.asyncapi.json). It passes a structural check: references resolve, every new message has the full envelope and a past-tense name, and each topic states its key, ordering, delivery, retention, replay and dead-letter handling. The only warnings are on the old OrderPlaced v1, documented as it runs today.

To confirm

  • The date the old OrderPlaced stops on orders: order service team with fulfilment.
  • Who watches each dead-letter queue: order service team with each consumer team.
  • How the orders topic is keyed today: order service team.
  • Whether analytics needs more than 7 days of history to replay; if so, longer retention or an archive: analytics with the platform team.
  • Whether loyalty awards points on OrderPaid (assumed) or OrderPlaced: loyalty.
  • Whether fulfilment reads customerEmail from today's event; if not, it can come off v1 now.

Checker output on the example

AsyncAPI check: examples/order-service.asyncapi.json
  2 channels, 4 messages, 2 operations
WARN  (deprecated) message OrderPlacedV1: envelope fields missing from headers and payload: event id (eventId), event type (eventType), event version (eventVersion), occurred-at (occurredAt), producer (producer), correlation id (correlationId)
WARN  message OrderPlacedV1: no causation id (causationId); consumers cannot tell which event caused this one
WARN  message OrderPlacedV1: x-sunset is 'to agree with fulfilment', not a date; agree the date and list it as open until then
PII (marked): message OrderPaid.payload.data.deliveryAddress
PII (marked): message OrderPlacedV1.payload.customerEmail
PII (marked): message OrderPlacedV1.payload.deliveryAddress
Result: 0 failures, 3 warnings

How it's checked

Measured against plain Claude Code (2026-10-23): it met 71% of its checks, against 39% without it, and a blind judge preferred it. How skills are checked

55 assertions across 7 acceptance evals, and 9 trigger queries. Every skill's checks

What the output must do
  • Does not publish an event named CreateShipment; renames it to a past-tense fact in the order domain such as OrderPaid, and explains that the warehouse decides to pick when it sees that fact
  • Declines the analytics request for one global order across all orders, keys the order events by order id, and says what ordering that gives (per order) and what it does not (across orders)
  • Puts the order events whose relative order matters (paid, cancelled) on the same channel with the same key
  • Drops customerEmail because no listed consumer needs it, and keeps the delivery address for fulfilment marked as personal data
  • Does not rename total to totalAmount in place on the live OrderPlaced event; ships the change as a new field beside the old one or a new version published alongside the old, with a deprecation date that is agreed with fulfilment rather than invented
  • Every message carries a required envelope: event id, event type, version, occurred-at in UTC RFC 3339, producer and correlation id; money is integer minor units, not 42.50
  • States at-least-once delivery and says how each consumer stays idempotent (for example dedupe on event id, or loyalty points keyed on order id)
  • States retention (7 days, from the brief), whether consumers can replay, and what goes to a dead-letter queue, and lists who watches it as open rather than inventing a team
  • Does not invent volumes, dates, team names or consumers not in the brief; anything assumed is marked as an assumption
  • Answers in the first lines: no, not for the order as a whole
  • Explains that Kafka orders only within a partition, so keying by paymentId orders one payment's events but gives no order across payments of the same order
  • Recommends keying by order id (or giving a per-order sequence the ledger can sort on) if the ledger needs order-level ordering, and notes that changing the key on a live topic is a breaking change
  • Says delivery is at-least-once and the ledger must deduplicate on an event id
  • Is a short answer, not a full AsyncAPI document
  • Does not invent partition counts, volumes or team details not in the question
  • Refuses to put the card number on the webhook and says so in the first lines, offering a card reference or last four digits instead
  • Leaves out the payer's email or justifies it with a named consumer need and marks it as personal data
  • Names the event as a past-tense fact such as InvoicePaid, with an event id, type, version, occurred-at in UTC and producer in every delivery
  • States that delivery is at-least-once with retries and that receivers must deduplicate on the event id
  • States that webhooks have no ordering guarantee and gives receivers a way to handle out-of-order or stale deliveries
  • Specifies a signature over the payload with a timestamp so receivers can verify the sender and reject replays
  • States the retry schedule or its cap and what happens to deliveries that keep failing, without inventing a customer or vendor name
  • Additive-only versioning is stated, and receivers are told to ignore unknown fields
  • Produces an AsyncAPI 3.0 document (asyncapi: 3.0.0, with channels and operations for the shipments topic and message schemas) plus a short decisions page, and also lists what is wrong with the current draft as asked.
  • Replaces the single imperative UpdateShipment / action enum with separate past-tense facts about the shipment, and specifically flags SEND_DELIVERY_SMS as a command to notify-svc rather than something that happened, removing it from the producer's contract.
  • Adds a required envelope with a unique event id and an unambiguous UTC (ISO 8601) occurrence time replacing the depot-local dd/MM/yyyy HH:mm timestamp, and ties the ~3,000 duplicate 'out for delivery' SMS after the rebalance to at-least-once redelivery, telling notify-svc and billing to deduplicate on the event id (billing must not charge or remit codAmount twice).
  • Changes the partition key from carrierId to shipmentId, explains that this gives ordering per shipment only (none across shipments or carriers), connects it to 'delivered' arriving before 'out_for_delivery', and warns that the planned 12 -> 48 partition increase in November remaps keys to partitions so per-shipment ordering can break around the change.
  • Applies data minimisation: drops recipient.dateOfBirth (and questions sending phone/email/address to every consumer, including analytics which keeps data forever), and marks whatever recipient PII remains as PII in the schema.
  • Rejects the Q1 plan to rename status -> state and switch weight from kg to grams as a v2 on the same topic, because both are breaking; proposes additive-only changes (e.g. a new field with an explicit unit) or a new topic/major version run in parallel with a dated deprecation for the old one.
  • Addresses retention, replay and dead letters: points out the 7-day default retention cannot support analytics replaying events from last year (needs longer retention, compaction or replay from the lake), and replaces billing's crash-restart loop on unparseable messages with a dead-letter topic and a defined redrive process; also flags codAmount as a float without currency.
  • Invents nothing that isn't in the input (no extra consumers, partition counts, incidents, field values or tooling claimed to exist beyond the two files), and answers the actual request directly — a contract plus the list of draft problems — without padding.
  • Answers the two questions (versioning, guaranteed delivery) directly and early, and tells the user plainly that exactly-once delivery over HTTP webhooks is not achievable: the honest guarantee is at-least-once, with a unique event id on every delivery that receivers store to deduplicate.
  • States that ordering cannot be guaranteed across retries and parallel deliveries, and gives receivers a way to cope (e.g. use updated_at or a per-invoice sequence number to discard stale events, or re-fetch the invoice from the API) rather than promising in-order delivery.
  • Spots that the ~40s ERP-sync endpoints will always exceed the 10s client timeout, so every delivery to them would be retried and duplicated; advises receivers to acknowledge with a 2xx quickly and process asynchronously.
  • Replaces 'retry 3x then give up' with retries on a backoff schedule over a longer window, and after retries are exhausted keeps the event (dead-letter / failed-delivery log) with a way for customers to see and replay or redeliver it, instead of silently dropping it.
  • On versioning, rejects 'change the payload when we need to' under an unchanging header: proposes a version pinned per endpoint/account, additive-only changes within a version, and breaking changes only in a new version with a dated deprecation window for the old one.
  • Applies data minimisation to the payload: does not send internal_notes, and drops or justifies customer.tax_id, customer.billing_address, customer.contact_email and payment.card_last4 (e.g. send a thin event with the invoice id and key fields, and let the receiver fetch details), marking any PII kept; also flags amount/unit_amount 1299.9 as a float and recommends integer minor units with currency.
  • Provides a compact AsyncAPI 3.0 document describing the webhook messages (a common envelope with event id, type and occurrence time, and messages for the paid/overdue/voided cases with past-tense names) plus a short decisions summary, and recommends signing deliveries (e.g. an HMAC signature header with a timestamp) so receivers can verify the sender and reject replays.
  • Invents nothing not in the input (no made-up existing Ledgerly endpoints, customer names, SLAs or numbers beyond 10s, 3 retries, ~40s and the example invoice), and keeps the answer sized to a chatty question — direct answers first, then the spec — rather than a sprawling design document.
  • Answers the actual request directly: delivers an AsyncAPI 3.0 document (asyncapi: 3.0.x, with channels plus operations using action send/receive, not 2.x publish/subscribe) for the orders topic, plus a short list of producer-side changes / decisions rather than a long essay.
  • Renames the event types create_order, update_order_status and cancel_order to past-tense facts (e.g. OrderPlaced, OrderCancelled) and replaces the generic update_order_status with specific past-tense events for paid/packed/shipped/delivered or an explicitly justified OrderStatusChanged.
  • Removes card_number and card_expiry from the event payload entirely (not merely tagging them) and marks customer_email, customer_phone and shipping_address as PII, dropping or justifying them per consumer since none of fulfilment, loyalty or analytics obviously needs all of them.
  • Replaces key=None (round-robin, which gives no ordering at all) with an explicit partition key, choosing order_id (or justifying customer_id), and states that ordering holds only per key within one partition; also flags that raising partitions from 6 to 24 before the November peak remaps keys to partitions and breaks per-order ordering across the change unless handled (e.g. drain first or create a new topic).
  • Flags that retention.ms 86400000 (1 day) cannot support analytics' 7-day Monday replay and recommends retention of at least 7 days (with margin) or a different replay source.
  • Defines a required envelope with a unique event id (plus type, occurred-at time, schema version), states delivery is at-least-once, tells consumers to deduplicate on the event id, and notes the producer's acks=1 / enable.idempotence=false setting as at odds with the guarantees (recommend acks=all and idempotence).
  • Flags that _publish runs before session.commit() so an event can be emitted for an order that then rolls back, and recommends publishing after commit via a transactional outbox (or equivalent).
  • Invents nothing not in the input: no extra consumer teams, fields, SLAs, throughput figures or tooling are presented as existing facts; any proposed values (retention length, deprecation dates, DLQ topic names) are labelled as proposals for the review.
  • Answers the actual request directly: returns a rewritten AsyncAPI 3.0 document for the Tallyfern invoice webhooks (operations with action, the 2.6 subscribe mapped to Tallyfern sending) plus a brief decisions note sized for a quick review, not a lecture.
  • Does not rename or retype amount in place: calls that a breaking change for existing partners, adds a new integer minor-unit field alongside the deprecated amount, and gives a dated deprecation/removal plan rather than shipping the swap next sprint.
  • Catches that the currency enum includes JPY (zero decimal places), so a field named amount_cents is wrong for it; uses minor units with the currency exponent (e.g. amount_minor) or otherwise defines the scale per currency.
  • Explains the duplicate invoice_paid deliveries as expected under at-least-once delivery with the 10s timeout and up-to-5 retries, adds a required unique event id that stays the same across retries, and tells partners to deduplicate on it and return 2xx quickly before processing.
  • Addresses Brightwell Payroll's paid-before-created report as a separate issue from duplicates: states webhooks have no ordering guarantee (retries can reorder them) and gives consumers a way to cope, such as an occurred-at timestamp or per-invoice sequence/version, tolerating unknown invoices, or fetching current invoice state.
  • Replaces 'after the 5th failure the event is dropped' with keeping failed deliveries (dead-letter) and a way for partners to replay or redeliver them, with any retention window marked as a proposal for the team to decide.
  • Marks customer.email and customer.tax_id (and name) as PII and minimises the payload, e.g. dropping tax_id or the embedded customer object in favour of an id, and flags that the HMAC signature covers only raw_body with no timestamp, so a captured request can be replayed; recommends signing a timestamp and enforcing a tolerance window.
  • Invents nothing not in the input: no partner names other than Brightwell Payroll, no endpoints, API routes, fields or existing features beyond those in docs/webhooks.yaml are presented as current fact; new elements are clearly proposed.
When it should and shouldn't activate

Should activate:

  • Write the AsyncAPI spec for the order events our service publishes to Kafka
  • Define the message schema, envelope and partition key for our payment events topic
  • We need to rename a field on a live SNS/SQS event without breaking the consumers. How do we version it?
  • Draft the EventBridge event contract for InvoicePaid, including what consumers have to do about duplicates
  • If we key our Kafka topic by customerId, is ordering guaranteed for each order?

Should leave to another skill:

  • Draft the OpenAPI spec for the orders REST API: create, fetch and list orders
  • Write a data contract for the orders table the analysts query in the warehouse, with freshness and completeness SLAs
  • Define the data contract for the clickstream topic that feeds the lakehouse, with null-rate and volume checks
  • Write a design doc for moving the order service from polling to an event-driven architecture

Hands off to

Related skills

api-contract-draft, design-doc

Source

SKILL.md · the whole folder on GitHub.