Catalogue / rules

Rules

Declarative predicates over a design. A rule reads a node's effective properties, its edges, its groups and the design's requirements. A finding never blocks a save; a structural problem always does. Open a rule for its rationale, the condition in plain English, and the reference designs that trip it.

Cache used as primary store without persistenceerrornode.kind = cache
cache-as-primary-store-without-persistence
Cache '{{node.id}}' is the primary store for its data but has persistence disabled.
This cache is the only copy of its data and has persistence turned off. A restart loses the data outright.
Synchronous call across cellserroredge
cross-cell-call
Synchronous edge '{{edge.from}}' -> '{{edge.to}}' crosses a cell boundary while the design requires cell isolation.
In a cell architecture each cell has to be able to fail on its own. A synchronous call that leaves the cell ties the two cells together and defeats the point of draining one.
Datastore without backup or retentionerrornode.kind = sql-database
datastore-missing-backup
Datastore '{{node.id}}' has no backup or no retention policy declared.
A system of record with backups off, or with no retention period stated, cannot be restored. The loss is discovered on the day it matters.
Single-region datastore behind a multi-region SLAerrornode.kind = sql-database
datastore-single-region
Datastore '{{node.id}}' is single-region with no replication, but the design requires multi-region availability.
The design promises multi-region availability, but this datastore sits in one region and does not replicate. A region outage takes the system of record with it.
Search index without an asynchronous feederrornode.kind = search-index
derived-store-without-cdc-or-rebuild
Search index '{{node.id}}' has no asynchronous feed from a stream, change data capture or worker. It cannot be rebuilt or kept in sync independently of the write path.
A search index is derived data and needs an asynchronous feed from a stream, change data capture or an indexing worker. Without one it is written inline on the request path, and it can never be rebuilt after it drifts.
Materialised cache without a rebuild patherrornode.kind = cache
materialised-cache-without-rebuild-path
Cache '{{node.id}}' holds materialised data but nothing durable or no worker feeds it. It cannot be rebuilt after loss.
A cache holding materialised views has to be rebuildable from a durable store or a worker. With nothing feeding it, a cold cache is a permanent gap rather than a slow start.
Money mutation without idempotency keyerroredge
money-mutation-without-idempotency-key
Edge '{{edge.from}}' -> '{{edge.to}}' moves money but does not declare idempotent, while the design requires exactly-once effect. Add an idempotency key to this hop.
A call that moves money is retried after a lost response. Without an idempotency key on that hop the retry becomes a second charge, which is exactly what the design's exactly-once-effect promise rules out.
Money store without double-entry ledgererrordesign
money-store-without-double-entry-ledger
Design '{{design.id}}' requires zero-sum money movement but has no ledger node. Record money in an append-only double-entry ledger.
The design promises that money movements sum to zero, but it has no ledger to enforce double entry. Balances kept in an ordinary table drift and cannot be audited.
Object store in fewer than three zones with a durability targeterrornode.kind = object-store
object-store-single-zone-with-durability-target
Object store '{{node.id}}' spans fewer than 3 zones (or declares none) while the design targets more than 10 nines of durability.
Eleven nines of durability assumes redundancy spread over at least three failure domains. An object store confined to fewer zones, or declaring none, cannot reach the target the design states.
Pub/Sub topic where replay is requirederrornode.kind = pubsub-topic
pubsub-replay-required
Topic '{{node.id}}' cannot replay messages, but the design requires replay. Use a stream or a replayable queue.
The design declares that consumers must be able to replay history. A fan-out topic that does not retain messages cannot deliver that.
Queue without dead-letter queueerrornode.kind = queue
queue-missing-dlq
Queue '{{node.id}}' receives async traffic but has no dead-letter queue.
A queue that receives asynchronous traffic and has no dead-letter queue either drops poison messages or retries them forever. Neither outcome leaves anything for a human to investigate.
Reconnect stampede without guarderrornode.kind = realtime-gateway
reconnect-stampede-without-guard
Realtime gateway '{{node.id}}' calls a coordination service synchronously on connect but has no reconnect stampede guard.
When a gateway holding millions of sockets restarts, every client reconnects at once and the gateway asks the coordination service about each of them. Without a stampede guard that lookup storm takes the coordination service down too.
Retry queue without dead-letter queueerrornode.kind = queue
retry-queue-without-dlq
Retry queue '{{node.id}}' has no dead-letter queue. Messages that keep failing must be parked for manual investigation.
A retry queue is where poison messages end up. With no dead-letter queue they are retried forever or dropped in silence, and nobody ever looks at the failed payment.
Retry without circuit breaker or load sheddingerroredge
retry-without-adaptive-shedding
Edge '{{edge.from}}' -> '{{edge.to}}' retries without a circuit breaker, and '{{edge.to}}' has no rate limiter in front of it. Retry storms will cascade.
Retries into a service that can neither shed load nor have its circuit opened produce a metastable failure: the retries keep the dependency saturated long after the original fault is gone. Either break the circuit at the caller or put an adaptive limiter in front of the callee.
Shared dependency without redundancyerrornode.kind = dns,coordination-service
shared-dependency-without-redundancy
Shared dependency '{{node.id}}' is not redundant, but the design requires redundant discovery.
The design requires redundant discovery, but this DNS or coordination service, which everything depends on, runs on a single provider or a single replica. Its failure is the whole system's failure.
Random channel placement under heavy-tail trafficerrornode.kind = realtime-gateway
shared-host-per-channel-without-isolation
Realtime gateway '{{node.id}}' assigns channels randomly while the design declares heavy-tail traffic. Hot channels will degrade unrelated tenants on the same host.
With heavy-tail traffic a few channels carry most of the load. Assigning channels at random lands those hot channels on arbitrary hosts, and every tenant sharing a host suffers for it.
Single-writer primary at peak QPSerrornode.kind = sql-database
single-writer-primary-at-peak-qps
Database '{{node.id}}' is a single unsharded writer declared at more than 5000 rps. Shard it or move to a multi-writer distributed store.
A single-writer, unsharded relational primary has a throughput ceiling. Declaring more than five thousand requests per second against it means the peak finds that ceiling in production.
Stateful engine pinned to a single zoneerrornode.kind = matching-engine,coordination-service,event-store
stateful-engine-pinned-to-single-zone
'{{node.id}}' is pinned to a single availability zone with no failover or replication.
A matching engine, coordination service or event store living in exactly one availability zone, with no failover and no replication, is lost with that zone. Everything that depends on it stops with it.
Stateful node without zone redundancyerrornode
stateful-node-without-zone-redundancy
Stateful node '{{node.id}}' lives in exactly one availability zone. A zone outage takes its state with it.
A stateful compute node pinned to exactly one availability zone disappears with that zone, and its state goes with it. Nodes inside a cell are exempt, because there the cell is deliberately single-zone and redundancy comes from the other cells.
Webhook without signature verification or idempotencyerrornode.kind = webhook-endpoint
webhook-without-signature-or-idempotency
Webhook '{{node.id}}' does not verify signatures or is not idempotent. Verify the caller's signature and deduplicate by event id.
A webhook is a public endpoint that its caller retries. Without signature verification anyone can post a fake event; without idempotency the provider's own retries apply the same event twice.
Several AI vendors and no gatewayinfodesign
ai-no-gateway
Design '{{design.id}}' calls three or more AI services and has no API gateway to route, retry and meter them.
With three or more AI vendors and no gateway, every caller holds every key, writes its own retries and fallbacks, and spend is spread across vendor dashboards. An AI gateway such as LiteLLM, Portkey or Cloudflare AI Gateway gives one place for keys, fallbacks, rate limits and cost.
Key where an identity would doinfoedge
edge-auth-key-where-identity
Edge '{{edge.from}}' -> '{{edge.to}}' authenticates with a key or a password to a cloud service that accepts an identity. Use managed-identity (Azure), iam-role (AWS) or service-account (Google Cloud).
The cloud behind this hop can authenticate the caller by an identity it never holds: a managed identity on Azure, an IAM role on AWS, a service account on Google Cloud. A key or a password has to be stored, rotated and kept out of logs, and a leaked one works from anywhere. Switch the edge's auth to the platform's identity.
Edge authentication not statedinfoedge
edge-auth-unset
Edge '{{edge.from}}' -> '{{edge.to}}' reaches data, secrets or an AI service and does not say how it authenticates. Set auth on the edge.
How a caller proves who it is decides what a leak costs: a password or a key in configuration can be copied and used from anywhere, a managed identity or an IAM role cannot. With security.edgeAuthDeclared set, the design has promised to say which on every such hop. Set auth on this edge: managed-identity, iam-role, service-account, workload-identity, api-key, oauth, mtls, basic or none.
AI API key not managedwarningnode.kind = ai-service
ai-api-key-unmanaged
AI service '{{node.id}}' is called with an API key, the key is not held in a key-management component, and the design has none.
This AI vendor is reached with an API key, and nothing in the design holds or rotates that key. It ends up in environment files or code, every caller shares it, and a single leak runs up the vendor bill until someone notices. Put the key in a key-management component, or say it is held there with keyStore.
Database updates not propagated to the search indexwarningnode.kind = sql-database
business-updates-without-index-propagation
Database '{{node.id}}' is written by application servers and the design has a search index, but no change-data-capture propagates its updates. Index writes rely on dual writes.
Application servers write this database and the design keeps a search index, but nothing captures the database's changes. The index then depends on the application writing to both places, and the two drift apart silently.
Cache without eviction policywarningnode.kind = cache
cache-missing-eviction-policy
Cache '{{node.id}}' has no eviction policy. Declare lru, lfu or ttl, or justify none explicitly.
A cache with no declared eviction policy inherits whatever the vendor chose, and most vendors default to evicting nothing. The cache fills up and then starts refusing writes.
Credentials without rotationwarningdesign
credentials-without-rotation
Design '{{design.id}}' holds data but declares no credential rotation period and has no key-management node.
The design stores data but says nothing about rotating the credentials that reach it, and it has no key-management node that would rotate them. A leaked credential stays valid forever.
Custom nodewarningnode.kind = custom
custom-node
Node '{{node.id}}' is custom: no rule can judge it. Replace it with a catalogue component or contribute one (docs/governance.md).
A custom node is a hole in the model: the catalogue has no word for it, so no rule can check anything about it. It is meant to be temporary, and naming it in the catalogue closes the hole.
Design has custom nodeswarningdesign
custom-nodes-present
This design has custom nodes; rules are blind to them. Aim for zero.
The design places nodes the catalogue does not name, so the rules are blind to that part of the architecture. The count belongs on the design, not only on the individual boxes.
Exactly-once delivery in front of an external side effectwarningedge
exactly-once-with-external-side-effects
Edge '{{edge.from}}' -> '{{edge.to}}' claims exactly-once delivery, but '{{edge.to}}' calls a third party synchronously. Exactly-once cannot cover that side effect; make the external call idempotent instead.
Exactly-once delivery stops at the consumer. When that consumer then calls a third party synchronously, the side effect sits outside the transaction and can still happen twice, so the exactly-once claim hides the need for an idempotent external call.
Inline external call without durable fallbackwarningnode.kind = app-server
external-call-inline-without-durable-path
Service '{{node.id}}' calls a third party inline with no queue or workflow engine to fall back to when that call fails.
A service that calls a third party synchronously, with no queue or workflow engine attached, has nowhere to park the request when the third party is slow or down. The failure becomes the user's problem instead of a retried job.
Fan-out inline in the request pathwarningnode.kind = app-server
fanout-inline-in-request-path
Service '{{node.id}}' calls a worker synchronously. Hand the job to a queue or stream so the request path does not wait for fan-out.
Calling a worker synchronously puts the background job on the request path. The caller waits for the slowest part of the fan-out.
Multi-leader store without a conflict policywarningnode.kind = nosql-database
multi-leader-store-without-conflict-policy
Store '{{node.id}}' is multi-leader across regions with no conflict resolution policy. Declare last-writer-wins, vector-clock or app-merge.
Multi-leader replication accepts concurrent writes in several regions. With no conflict resolution policy declared, divergent replicas are reconciled by accident rather than by design.
Push fan-out without a celebrity thresholdwarningnode.kind = worker
push-fanout-without-celebrity-path
Worker '{{node.id}}' pushes fan-out over a social graph with no fanoutThreshold. Declare a follower threshold above which timelines are merged at read time.
Pure push fan-out over a social graph is brutal at the tail: one post from an account with millions of followers becomes millions of cache writes. A follower threshold above which readers merge at read time bounds that write amplification.
Read-heavy datastore without a cachewarningnode.kind = sql-database,nosql-database
read-heavy-path-without-cache
Datastore '{{node.id}}' serves a read-heavy workload directly from application servers and the design has no cache.
The design states a read-heavy workload and application servers read this datastore directly, yet the design has no cache anywhere. Every read pays a database round trip.
Read replicas as the only scalingwarningnode.kind = sql-database
read-replica-as-sole-scaling
Database '{{node.id}}' relies on async read replicas as its only scaling above 5000 rps. Replicas do not scale writes.
Asynchronous read replicas scale reads only. When the store is neither sharded nor distributed the writes still land on one primary, so the replicas add nothing at the declared peak.
Retry queue without backoffwarningnode.kind = queue
retry-without-backoff
Retry queue '{{node.id}}' declares no backoff. Retries without backoff amplify the outage they are retrying through.
Retrying a transient failure at full speed turns one slow dependency into a retry storm. A retry queue backs off, preferably exponentially and with jitter.
Shared worker pool across queueswarningnode.kind = worker
shared-worker-pool-across-queues
Worker '{{node.id}}' consumes more than one queue while the design requires per-queue isolation. Give each queue its own worker pool.
One worker pool consuming several queues lets a slow or poisoned queue starve the others. That is precisely what the design's per-queue isolation requirement forbids.
Stateful hash ring without ring managerwarningnode.kind = app-server
stateful-hash-ring-without-ring-manager
Service '{{node.id}}' is on a consistent hash ring but is not connected to a coordination service that manages the ring.
A stateful service on a consistent hash ring needs something that owns the ring: detect a dead host, pick a replacement, tell everyone. Without a coordination service the ring is edited by hand during the outage.
Stateful WebSocket scale-in without drainwarningnode.kind = realtime-gateway
stateful-websocket-scale-in-without-drain
Realtime gateway '{{node.id}}' autoscales but does not drain connections on scale-in. Every scale-down becomes a reconnect storm.
Scaling a gateway down kills every live WebSocket on the removed instance at once. Without a drain step those clients all reconnect together.
Synchronous call chain too deepwarningnode.kind = api-gateway
sync-chain-too-deep
Entry point '{{node.id}}' starts a synchronous call chain deeper than 3 hops while the design states a p95 latency target.
Every consecutive synchronous hop from this entry point spends part of the stated p95 latency budget, and the chain's failures multiply. More than three hops is a budget nobody is tracking.
Event-time windows without watermarkwarningnode.kind = stream-processor
windowed-aggregation-without-watermark
Stream processor '{{node.id}}' windows by event time but declares no watermark. Late data handling is undefined.
Event-time windows need a watermark to know when a window is complete. Without one the processor either waits forever or closes windows early and silently drops late events.