← wiregraph

Deep dive

A contract is a defined communication between two compartments.

Not just microservices. Any two compartments β€” two services, a library and its consumer, two programs sharing a table β€” agree on a shape and talk through it. That agreed shape is the contract. It's the seam a call graph never records, because no function ever calls across it.

COMPARTMENT A COMPARTMENT B writes reads contract the agreed shape

The boundary is the point. Inside a compartment, everything resolves β€” you can follow calls symbol to symbol. Cross the boundary and there is no call to follow: A doesn't invoke B, it just conforms to the same shape B expects. The contract is that shape, made into a node so the crossing becomes traceable.

The variety

Same idea, three surfaces.

A contract isn't only an HTTP call between services. It's any shape two compartments agree on β€” over a wire, through an exported API, or in shared state.

service ↔ service

The wire

An HTTP route or a queue topic. One service serves or publishes it; another calls or consumes it.

svc A svc B POST /orders HTTP Β· queue topic
library ↔ consumer

The exported API

A package exposes a surface β€” functions, types, an SDK. Another repo imports it and depends on those names.

@acme/sdkexports app repoimports createClient IMPORTS edge Β· or an INPROC contract
program ↔ program

Shared state

An env var, a database table, a file on disk. Two programs never speak β€” they meet at a shared key.

prog A prog B DATABASE_URL

The invisible bug class

Rename one side. The other breaks β€” silently.

Because no code calls across the seam, nothing in either repo references the other. Your compiler is happy. Your tests in each repo pass. The break only surfaces at runtime, in production, between the two.

REPO A emits /api/register REPO B handles /api/register contract /api/register seam intact β€” producer and consumer share the token
βœ• grep for /api/register in repo A β€” clean βœ• a call graph β€” no edge ever crossed the repo βœ“ wiregraph β€” the contract's two edges no longer agree

How wiregraph detects it

Four detectors. One rule.

Each detector pulls a candidate token out of the code. When the same token shows up in 2+ repos, wiregraph mints a Contract node and links each side to it.

wire

HTTP routes

A route one repo serves and another calls.

/api/register
β†’ REFERENCES a Contract
message

Queues / topics

A topic a producer emits and a consumer handles.

orders.created
β†’ REFERENCES a Contract
state

Shared state

An env var or key two services both touch.

DATABASE_URL
β†’ REFERENCES a Contract
import

Library / SDK

A package surface one repo exposes, another imports.

import @acme/sdk
β†’ IMPORTS the package
βš‘
Detection is a heuristic, and contracts are proposed, not written. wiregraph reads the shared tokens straight out of your code and drafts the seam β€” it never edits your source or invents a spec you didn't declare. Run /wiregraph-contracts to review the inferred wires as a draft AsyncAPI spec before you trust them.
βš‘
Wire contracts are AsyncAPI β€” 2.x and 3.x are both read. A wire spec is an <name>.asyncapi.yaml (or .yml) in a directory named contracts/, asyncapi/ or *-contracts. The version decides only where the route is written, and wiregraph reads it in the right place for the version it declares. In 3.x the route is channels.<key>.address and the channel key is an arbitrary id that is deliberately not a token (naming a 3.x channel submit-order mints nothing on its own); direction comes from the top-level operations: map. In 2.x there is no address: field: the channel key is the address and is read as the token, rooted (/alerts/{id}/escalate) or unrooted (smartylighting/streetlights/event/measured, the idiomatic 2.x spelling), and both forms match the route as your source writes it, {param} segments included; direction comes from each channel's own publish / subscribe. Payload field names under properties: are tokens in both versions. A version that is neither 2 nor 3 is warned about by file and by version β€” a 1.x or 4.x doc is read with the 3.x rules, which may find nothing, and the build logs ⚠ <file>: asyncapi <version> β€” wiregraph reads AsyncAPI 2.x and 3.x only. A spec with no asyncapi: line at all is treated as 3.x-shaped. Separately, a spec that ends up with zero matchable tokens gets its own named warning (⚠ <file>: … this spec defines NO matchable tokens) β€” that, not the version, is the line to look for when a contract loads and governs nothing.
asyncapi: '3.0.0'            # 2.x is read too β€” see the 2.x example below
info:
  title: Terminal Wire       # the contract id β€” UNIQUE across all specs
  version: 0.1.0
channels:
  submit-order:
    address: /orders/{id}/items          # matched token, kept parameterized
    x-wiregraph-producers: [terminal]    # COMPARTMENT names that call it
    x-wiregraph-consumers: [orders_api]  # COMPARTMENT names that serve it
    messages:
      request:
        payload:
          type: object
          properties:
            order_id: { type: string }   # payload fields are tokens too
operations:
  receive-submit-order:
    action: receive                      # server receives β‡’ client-to-server
    channel: { $ref: '#/channels/submit-order' }
    messages: [{ $ref: '#/channels/submit-order/messages/request' }]

The same contract in 2.x, where the channel key carries the route and the channel's own publish/subscribe carries the direction:

asyncapi: '2.6.0'
info:
  title: Terminal Wire       # the contract id β€” UNIQUE across all specs
  version: 0.1.0
channels:
  /orders/{id}/items:                    # 2.x: the KEY is the address = the token
    x-wiregraph-producers: [terminal]
    x-wiregraph-consumers: [orders_api]
    publish:                             # 2.x: published INTO the app β‡’ client-to-server
      message:
        payload:
          type: object
          properties:
            order_id: { type: string }   # payload fields are tokens too
  • info.title is the id. It must be unique across every spec in the project, in both formats β€” two specs sharing a title merge into one contract node.
  • Two kinds of token are matched against your code: every channel address (3.x address:, 2.x the channel key), and every key under any properties: map. An address starting with / is matched as a route, with each {param} segment matching however the source writes it (:id, ${id}, {id}, or a concrete value); an address that does not start with / is matched literally, which is how topics, 2.x channel keys, routing keys and env-var names work.
  • Tokens must be distinctive or they are dropped: a route needs 5+ characters and must not be a generic endpoint (/health, /metrics, /api, /status, /version…); a non-route needs a dotted/colon key, an underscore with 6+ characters, or a 10+ character camelCase name. Generic words (id, type, state, message…) and ubiquitous env vars (DATABASE_URL, PORT, LOG_LEVEL…) are always dropped β€” a generic token would match every file and manufacture phantom seams.
  • x-wiregraph-producers / x-wiregraph-consumers are what make the edge directed. They are arrays of compartment names on the channel β€” the names graph_status reports. Omit them and you still get references, but a weaker seam.
  • Direction is read wherever the version keeps the operations, server-perspective either way. In 3.x that is the top-level operations: map: action: receive tags that operation's fields clientβ†’server, action: send tags them serverβ†’client, and an operation's reply.messages get the opposite. In 2.x it is the channel's own members: publish is published into the app (clientβ†’server) and subscribe is what the app publishes out (serverβ†’client). A /-leading token with no operation on either shape defaults to clientβ†’server.
βš‘
A mention in a comment is not a reference. A token named only inside a comment mints no contract edge β€” a comment quoting a route is not an implementation of it. If you are upgrading a graph built by an older wiregraph, a legacy contract whose only reference on one side sat in a comment flips from satisfied to one-sided at its next full rebuild. That is the true reading arriving, not a regression: that side was never implemented.
βš‘
Resource contracts β€” coupling through a shared thing rather than a wire: a file or sentinel path, a DB table+key, a shared-memory region, a named pipe. The roles are writer and reader, not request and reply, and the semantics are presence/state. They live in a <name>.resource.yaml in your contracts dir listing each resource's id, kind, semantics, writers and readers, and trace_contract / path_between traverse them exactly like a wire. /wiregraph-contracts drafts them for you from constants two compartments share β€” it can see that they share one but never which side writes, so an inferred draft lists every compartment on both sides and you prune it. The id is the shared constant name (GAME_STATE_PATH), never the path literal: wiregraph is deliberately literal-blind, so a compartment that only ever writes '/var/run/game/state.json' and never names the constant is missed by design. Declare single_writer: true and a second declared writer is reported as a violation instead of being quietly merged — but note the limit: wiregraph detects references, never writes, so it cannot identify an undeclared writer as a writer at all. What it does report, exactly, is an undeclared participant: a compartment whose code touches the resource while the spec names it as neither writer nor reader.
βš‘
In-process contracts β€” the third type, and the one for two compartments in one process: a crate or module wall crossed by direct use of the other side's exported symbols, an ecs crate whose World and Scheduler the sim crate is built on. There is no wire and no shared file here, and wiregraph never resolves a call across a compartment boundary by name β€” so without a contract a four-crate workspace indexes as four disconnected islands. The join key is the one string both sides already spell identically: the exported symbol name. They live in a <name>.inproc.yaml, the roles are provider and consumer, and trace_contract / path_between walk the derived INPROC edges exactly like a wire.
title: ecs-sim                 # -> contract name; UNIQUE across every spec, all formats
boundary: crate                # crate | module β€” DOCUMENTATION of what the seam crosses
symbols:
  - id: World                  # the exported SYMBOL NAME, bare
    kind: type                 # type | function | method | trait | macro β€” descriptive only
    provider: ecs              # the ONE compartment that DEFINES it
    consumers: [sim]           # the compartments that USE it
  - id: Scheduler
    kind: type
    provider: ecs
    consumers: [sim]
  • Direction is one-way by construction. There is exactly one provider:, and a spec naming a compartment as both provider and consumer of one symbol is refused, not warned about β€” a compartment using its own exported symbol is the call graph's job, not a seam. This is the one place wiregraph can check “direction is one-way where it can be” at all.
  • The id is a bare symbol name. World, not ecs::World and not World<T> β€” the matcher builds \bWorld\b, and a qualified or parameterised spelling is not what the definition or the call sites write. An id containing / is rejected outright (a /-bearing token is matched as an HTTP route, which is wrong for a symbol), as is anything under 3 characters or a generic word (type, state, name…) that would match essentially every file ever written.
  • Short ids are ACCEPTED here, unlike everywhere else β€” deliberately. Every wire and resource token must pass the distinctiveness gate; an in-process id cannot and still be useful, because the exported surface of an ECS crate is World, Entity, Scheduler, Query. Applying the gate would make the feature refuse its own reason for existing, so it is not applied β€” and the loader names the short ids once per spec instead of pretending they are safe.
  • unreferenced is strong; satisfied is weak. Read the drift report asymmetrically. πŸ”΄ unreferenced means nothing in scope spells the name at all β€” act on it. satisfied means only both compartments spell this name, never the consumer uses the provider's one. Matching is literal and case-sensitive (a local world is safe); comments and use/import lines are excluded; but an unrelated same-named symbol inside a declared compartment is indistinguishable from the real use, and a re-export under another local name (use ecs::World as W), a type alias or a macro-pasted name is missed entirely.
  • The blast radius is bounded by the declared roles. Only symbols in the declared provider compartment are paired with symbols in the declared consumers, so a stray World in a third crate can never mint an INPROC edge. It surfaces instead as an ⚠️ undeclared participant β€” the honest report, “something else in this project spells this name, go look” β€” and for this contract type that finding is as likely to be a false positive as an incomplete spec. Look before you widen the spec.
  • An INPROC edge is not a resolved call. It says a symbol on each side spells the declared name. Calls still do not resolve across a compartment boundary; letting a contract authorize that resolution is a separate, later change.
  • There is no inference for this format. /wiregraph-contracts drafts wire and resource specs; it never proposes an *.inproc.yaml. Write it by hand β€” there is no draft coming.
βš‘
The import detector is language-limited, and it is worth knowing which side of the line you are on. An IMPORTS edge is resolved for TypeScript/JavaScript β€” a relative specifier, or a bare one matching another compartment's package.json name β€” and for C's quoted #include "…". Python, Java, Kotlin and Rust emit no import candidates at all: a Rust use path names a crate-relative namespace rather than a file, and a guess that lands on the wrong one is a false edge. In those languages an in-process cross-compartment link is described by a contract or it is not in the graph.
βš‘
Contracts at every level β€” a contracts dir governs its parent. A tree whose compartments nest (a server/ split into crates, a client/ split into modules) can put a contracts/ directory at each level, and in recursive mode (/wiregraph-init sets it) wiregraph finds all of them, not just the one at the root. The subtree a contract governs is the parent of the directory holding it: server/contracts/ applies to files under server/ and nowhere else, the root's contracts/ applies to everything. That is not a naming convention β€” it is the placement rule of the architecture recursive mode was built for: a contract belongs to the shared parent of its two sides, never inside either one. File a contract inside one of its own sides and wiregraph will not say “misfiled”, but you will see the consequence: its scope covers only that side, the other half's references fall outside it, and the token reports one-sided.
  • The innermost contract wins a shared token. When an outer and an inner contract both declare the same route, code under the inner one's subtree belongs to the inner contract β€” the same nearest-ancestor rule that decides which compartment a file is in.
  • Shadowed is not missing. The outer contract then legitimately sees only the halves outside that subtree. trace_contract reports that as β“˜ shadowed, names the narrower contract, and keeps it out of the πŸ”΄ drift count β€” so the drift flag still means something on a nested layout.
  • info.title must be distinct across scopes. Two specs sharing a title merge into one contract node, which would erase both scopes and let each spec's tokens match the other's subtree. That collision is refused by name, keeping the outer spec.
  • Name the compartments that hold the code. Compartment attribution is flat: every file belongs to its nearest compartment, so a parent whose children are compartments in their own right holds only the files that are in none of them. A role naming server when the code lives in server/ecs, server/sim, … matches almost nothing and reports one-sided, with nothing said about why.
βš‘
A hand-packed binary protocol needs one discipline to be traceable. Every join wiregraph makes is a string two compartments both write down β€” a route, a topic, a payload field name, a constant name. A protocol that packs integer message tags into bytes over a socket puts none of those in the source, so a contract describing it matches nothing on either side and reports as drift, however correct the document is. The fix is not a setting. Give each message type a named constant, spelled identically in both languages, declare those names as the contract's tokens, and use them.
// server/network/src/codec.rs
pub const MSG_ENTITY_DELTA: u8 = 0x03;
pub const MSG_INPUT_FRAME: u8 = 0x04;

fn encode_delta(buf: &mut Vec<u8>, d: &Delta) {
    buf.push(MSG_ENTITY_DELTA);          // a USE β€” this is what mints the edge
    …
}

// client/network/src/decode.ts
export const MSG_ENTITY_DELTA = 0x03;
export const MSG_INPUT_FRAME  = 0x04;

export function dispatch(view: DataView) {
  switch (view.getUint8(0)) {
    case MSG_ENTITY_DELTA: return readDelta(view);   // the other half
    …
  }
}
# contracts/server-client.asyncapi.yaml β€” beside the prose contract, not instead of it
asyncapi: '3.0.0'
info:
  title: Server ⇄ Client Wire
  version: 0.1.0
channels:
  entity-delta:
    address: MSG_ENTITY_DELTA            # no leading slash β‡’ matched literally
    x-wiregraph-producers: [server_network]   # COMPARTMENT names, as the graph knows them
    x-wiregraph-consumers: [client_network]
  input-frame:
    address: MSG_INPUT_FRAME
    x-wiregraph-producers: [client_network]
    x-wiregraph-consumers: [server_network]
  • Declaring the constant is not using it. The line that declares a named constant is excluded from matching on purpose β€” a constants module that only defines the name would otherwise become a phantom half of the seam. Each side needs at least one real reference, which the encoder and the switch above are.
  • The name has to survive the distinctiveness gate. MSG_ENTITY_DELTA passes (an underscore and 6+ characters). Delta, kind and type do not, and would be dropped β€” deliberately, since a generic token matches every file and manufactures seams.
  • Both sides must spell it the same. The join is the literal string; MSG_ENTITY_DELTA in Rust and MsgEntityDelta in TypeScript is two tokens, and the seam stays dark. A vendored pair of constants kept in step by hand is the price of a protocol with no strings in it.
  • Only YAML specs are read. A contracts/ directory holding prose contracts keeps them; wiregraph reads *.asyncapi.yaml, *.resource.yaml and *.inproc.yaml and ignores every other file. The spec is a machine-readable projection of the part that can be string-matched, not a replacement for the document.

Sources

Standing on prior art.

Contract architecture isn't new β€” wiregraph just makes the seams queryable. The thinking it builds on:

Ready

Give Claude the whole picture.

Index once, and Claude traces producer β†’ contract β†’ consumer across every seam β€” at about half the tokens.

Install wiregraph  β†’