Boundary semantics
The IR's BoundaryBinding has all three layers of a boundary description as separate fields: what bytes travel (transport), what the two sides mean by the exchange (semantics), and how a particular library expresses that in source code (recognition).
Ten semantics variants ship today: rest, function-call, graphql-resolver, graphql-operation, runtime-config, storage, message-bus, metric, unit-invocation, and io, each as its own module under packages/ir-core/src/semantics/. To find out whether a protocol already works, see What's shipped vs what's deferred. The sections before it describe the model those ten variants share.
The three layers
A "boundary" in suss is three things at once, which the IR stores as sibling fields on BoundaryBinding:
Transport
Transport is what bytes travel on the wire.
- HTTP / HTTPS
- TCP, AMQP, Kafka's own framing
- In-process function call
- AWS SDK over HTTPS (to an AWS service API)
Transport matters for tooling concerns such as authentication, retries, transport-level errors, TLS and timeouts. It does not affect whether the provider's contract matches what the consumer reads, so cross-boundary checking mostly ignores it.
Semantics
Semantics is what the two sides mean by the exchange. Cross-boundary checking depends on this layer.
- REST resource: discriminated by HTTP status code; payload is the response body (typically JSON). Pairing key:
(method, normalizedPath). - GraphQL operation: discriminated by
errors.length === 0plus per-field nulls indata; payload is the structureddataobject. Pairing key:(typeName, fieldName)for resolver-level; operation-to- resolver mapping viapairGraphqlOperations. - Lambda direct invoke: discriminated by
FunctionError === undefinedvs"Handled"vs"Unhandled"; payload isPayload. Pairing key:FunctionName. The HTTP layer is invisible to anaws-sdkconsumer. - Kafka consume: discriminated by topic + message headers; payload is
valueplus headers. Pairing key: topic. - Queue job (SQS, BullMQ, …): discriminated by job type; payload is job arguments. Pairing key: queue name + job name.
- In-process function call: discriminated by thrown exception type vs normal return; payload is the return value.
- React component ↔ DOM: one component source produces several code units that share a component identity: the render body (inputs=props/ state/context, output=JSX tree), one code unit per event handler (inputs=synthetic event + closed-over state, outputs=state mutations + callback-prop invocations), and one per
useEffectbody (inputs=dependency array, outputs=side-effects + optional cleanup). The discriminator is the unit kind, and the payload is the tree or the effect it produces. Pairing key:(component identity, unit kind, unit name?). The multi-unit framing comes from a design record, the React roadmap, which plans work still to come. - gRPC unary call: discriminated by gRPC status enum (its own code space, not HTTP status); payload is the response message. Pairing key:
(service, method).
One transport can serve many semantics. REST, GraphQL, and Lambda all travel over HTTPS but describe entirely different kinds of boundary. It works the other way too: one semantics can travel over several transports, since an SQS queue and a Kafka topic are both message-queue semantics with different transports.
Recognition
Recognition is how a particular library expresses a given semantics in source code. Today's PatternPack describes it, and responseSemantics covers part of it.
- For REST semantics, axios recognises the response via
.dataand the status via.status; fetch via.body/.json()/.status; ts-rest via the.bodyof its typed result. All three are REST packs with different recognition rules. - For GraphQL semantics, urql exposes
{ data, error }; Apollo client exposes{ data, error, loading }; a raw fetch wrapper around a GraphQL endpoint exposes the same structure a REST call does and needs a different recognition strategy. - For Lambda invoke semantics,
@aws-sdk/client-lambdareturns{ StatusCode, FunctionError, Payload, LogResult, ExecutedVersion }; a direct call throughlambda.invoke().promise()(v2 SDK) returns something different.
Each pack has its own recognition rules. Semantics says what the pack is describing in the end, whatever those rules look like.
Shipped shape
packages/behavioral-ir/src/schemas.ts exports BoundaryBinding as:
interface BoundaryBinding {
/** Wire protocol (http, tcp, amqp, in-process, aws-https, …). */
transport: string;
/**
* What the participants think they're doing. The checker dispatches
* on the discriminator (`semantics.name`).
*/
semantics: Semantics; // discriminated union, see below
/** Pack-level recognition identity ("axios", "ts-rest", "openapi", …). */
recognition: string;
}Semantics is a discriminated union of nine variants today:
type Semantics =
| { name: "rest"; method: string | null; path: string | null; declaredResponses?: number[]; host?: string; optionalFormat?: boolean }
| { name: "function-call"; module?: string; exportName?: string; package?: string; exportPath?: string[] }
| { name: "graphql-resolver"; typeName: string | null; fieldName: string }
| { name: "graphql-operation"; operationType: "query" | "mutation" | "subscription"; operationName?: string }
| { name: "runtime-config"; deploymentTarget: "lambda" | "ecs-task" | "container" | "k8s-deployment"; instanceName: string }
| { name: "storage"; storageSystem: string | null; scope: string; container: string | null; accessPath: string | null }
| { name: "message-bus"; messageBus: "aws_sqs" | "aws.sns" | "s3" | "eventbridge" | "bullmq" | "kafka" | "nats"; channel: string | null }
| { name: "metric"; metricSystem: string; metricType: string | null }
| { name: "unit-invocation"; deploymentTarget: "lambda" | "ecs-task" | "container" | "k8s-deployment" | "worker"; instanceName: string | null }
| { name: "io"; target: string | null };An identity field is null when the source never states it. A queue URL that comes from a variable is the common case:
{ "name": "message-bus", "messageBus": "aws_sqs", "channel": null }The send is recorded, and it pairs with nothing. The empty string is invalid in these fields, and the builders throw on it. REST's method also allows "*", which means the handler responds to every method.
Semantics in use today
rest is the case most of the dispatch is built around: pairing, provider coverage, consumer satisfaction, body compatibility, and semantic bridging all read semantics.name === "rest" and narrow to method + path. A null method or path means the source never stated one, and boundaryKey returns null for these, which keeps them out of automatic pairing. A "*" method groups by path and pairs with whatever method each consumer uses.
A client that writes out an absolute URL keeps the host in host. A host that can be the project's own, such as localhost:3000 or a container's service name, is dropped before pairing. Any other host stays in the pairing key, so a call to another company's API never meets the project's routes, a catch-all included. A client whose host or base URL suss could not read still pairs on its path, but never with a catch-all route such as /{url+}.
When a call meets several routes, the route that spells out more of the segments the call spells out wins. A route that spells out a word where the call has a hole, such as /follows/bulk_show for a call to /follows/{id}, meets the call only if the id is that word, so it does not pair at all.
function-call handles in-process units (React components, bare function exports, Storybook contract components) that don't take part in REST pairing. It has two separate identity slots, because library consumers and callers inside the same repo look each other up through different keys:
module/exportName: a repo-relative module path and a named export within it. Packs that pair inside a single repo use these.package/exportPath: a package name ("@suss/behavioral-ir") and the path to the export within the package. ThepackageExportsdiscovery variant sets these. The checker's pairing key forfunction-callreads these slots asfn:<package>::<exportPath>and pairs package exports that way; pairing onmodule/exportNamewithin a repo is not wired up yet.
A React component found in the repo and the same component imported from a shipped package are different bindings. Treating them as one would lose track of where each came from.
graphql-resolver and graphql-operation both ship. Resolver-level pairing keys on gql:${typeName}.${fieldName}. Operation-to-resolver pairing runs through pairGraphqlOperations (in packages/checker/src/pairing/), which walks the operation's selection set and pairs root selections against the matching graphql-resolver provider. checkGraphqlContractAgreement then compares metadata.graphql.declaredContract across the sources that declare it, checking that the return types are compatible and the argument sets agree.
runtime-config treats the env-var channel of a deployable unit as a boundary. Env var names are fields on that channel's contract, the same way response body fields are fields on a REST endpoint's contract. Pairing key: (deploymentTarget, instanceName). The env var list is in metadata.runtimeContract.envVars, and metadata.codeScope says which source files run inside the channel.
storage covers every store: a Postgres table declared through Prisma, Drizzle, TypeORM, or raw DDL, and a DynamoDB table, a bucket, or an index the same way. The container is the table, bucket, or collection, and accessPath is a secondary way in, a global secondary index or an alias, or null for the container's own primary key. What a store declares an item has are fields on the container's contract, and field-level access checks compare what the code reads and writes against metadata.storageContract.fields. Pairing key: (storageSystem, scope, container, accessPath). storageSystem is null on a store whose deploy configuration picks its engine from a variable, and a null there meets an access on any engine.
Whether a field the code touches can be called unknown depends on what the provider declares in metadata.storageContract.fieldSet, whatever the store is called. fieldSet is "exhaustive" for a SQL schema that declares every column, "partial" for a store that declares its keys and lets the rest vary, and "none" for a blob. Only an exhaustive contract produces boundaryFieldUnknown. metadata.storageContract.identifies says what picks one item out of the container, either the key fields in the order the store keys on them or a convention the key follows.
Two declared stores on the same key are two declarations of one table, and they are compared with each other column by column. Each field's column is the name the database stores it under when that differs from name, and type, nullable, primary, default and foreignKey are what is compared. origin says whether a declaration is a dump of the database or a schema with its own migrations, which decides the side that speaks for the database.
message-bus covers SQS, SNS, S3, EventBridge, BullMQ, Kafka, and NATS. Producer-side interaction(class: "message-send") effects pair against it, and consumer-side handlers get the same binding from the deployment-manifest contract source (CFN event-source mappings and similar). Pairing key: (messageBus, channel). A send whose queue the code works out at runtime has a null channel. A receive effect always has one, because the event-source mapping states which queue the handler drains, and the checker joins the two by code scope.
metric is a named series of measurements: one side declares it, another side reads it back by the type string the monitoring system gives it. Neither side can see the other's declaration, so the type string is the whole identity. Pairing key: (metricSystem, metricType). Whether a measurement is one number or a histogram is something only the declaring side can state, so it goes on that summary's metadata, the way a storage contract's field list does.
io is one of the process's own streams, standard output or standard error. Only the side that writes is ever in a run, since whoever reads the stream is a person, a shell or a test, so nothing pairs with it. It is keyed io:stdout anyway, so a question, an intent document and a review diff all spell the stream the same way. A write to a stream the reader could not settle has a null target and no key.
unit-invocation is a deployed unit something else calls by name: a Lambda another Lambda invokes, a Cloud Function, a state machine. Its identity is the platform plus the name the platform knows the unit by, which is exactly a DeployableUnit, so the two fields come from DeployableUnitSchema and a unit's config channel and its invoke channel key the same way. Pairing key: (deploymentTarget, instanceName), spelled unit:lambda ReportBuilder.
An ARN is one way of spelling that name. It has an account and a region in it, so a dev ARN and a prod ARN refer to one function. resourceNameIn reduces one to the name where the effect is recorded, so the two sides compare the part both can know. A name that only exists at deploy time reaches the code as an env var, and deployedRefs collapses that chain against the invoking unit's own environment, the same way a queue URL is collapsed. Every reader of a boundary name takes that step through groundBinding, so the pairing pass, a drafted intent document and the intent checker all arrive at one name. See grounding a deploy-time name.
Pack helpers
@suss/behavioral-ir exports ten builder helpers so packs don't hand-roll the three-layer structure themselves:
restBinding({ transport, method /* string | null */, path /* string | null */, recognition, declaredResponses? })
functionCallBinding({ transport, recognition, module?, exportName?, package?, exportPath? })
packageExportBinding({ recognition, packageName, exportPath, transport? })
graphqlResolverBinding({ transport, recognition, typeName /* string | null */, fieldName })
graphqlOperationBinding({ transport, recognition, operationType, operationName? })
runtimeConfigBinding({ recognition, deploymentTarget, instanceName })
storageBinding({ recognition, storageSystem /* string | null */, scope, container /* string | null */, accessPath?, transport? })
messageBusBinding({ recognition, messageBus, channel /* string | null */ })
metricBinding({ recognition, metricSystem, metricType /* string | null */ })
unitInvocationBinding({ recognition, deploymentTarget, instanceName /* string | null */ })
ioBinding({ recognition, target /* string | null */ })The builders throw on an empty string in an identity field. Write null when the source does not state a value.
packageExportBinding is a thin wrapper over functionCallBinding that makes call sites declarative. It defaults transport to "in-process".
Where the words come from
A summary says what a unit can reach, and a trace says what it did reach. Neither static analysis nor observability compares the two today, and comparing them needs both sides to spell a boundary the same way. So wherever OpenTelemetry's semantic conventions have a word for something in a binding, suss uses their word, and the rest of the vocabulary is suss's own. suss does not emit traces. A span is still a record of one execution, and a summary is still a statement about a unit.
Values suss borrows
| suss field | values | OpenTelemetry attribute |
|---|---|---|
storage.storageSystem | postgresql, mysql, sqlite, redis, aws.dynamodb | db.system.name |
storage.scope | the database, schema, or keyspace | db.namespace |
storage.container | the table, bucket, or collection | db.collection.name |
message-bus.messageBus | aws_sqs, aws.sns, kafka | messaging.system |
message-bus.channel | the queue, topic, or subject | messaging.destination.name |
rest.method | GET, POST, … | http.request.method |
rest.path | /users/{id} | http.route |
graphql-operation.operationType | query, mutation, subscription | graphql.operation.type |
graphql-operation.operationName | the document's name | graphql.operation.name |
aws_sqs has an underscore and aws.sns has a dot because that is how the conventions spell them, one predating the other.
Summaries written before this used postgres, dynamodb, sqs, and sns. They read back with the new names through the normalizer in @suss/behavioral-ir, which brought the format to schema version 5; the metric words above followed at version 6. Pack config takes the new name too: a project passing { "storageSystem": "postgres" } to the sqlalchemy, activerecord, prisma or drizzle pack writes postgresql instead. The CLI parses a pack config against the pack's own declaration, so the old spelling stops the run with a sentence telling you to change it.
Field names suss keeps
An attribute name is a flat namespaced key (db.system.name), and an identity field is a member of a union that semantics.name already namespaces. Renaming the fields would leave a consumer working out the prefix anyway, so the fields keep their names and each protocol module declares which attribute each field goes under:
semconv: {
storageSystem: { name: "db.system.name" },
scope: { name: "db.namespace", placeholderValues: ["default"] },
container: { name: "db.collection.name" },
},semconvAttributes(binding) reads a binding through those declarations:
semconvAttributes(binding);
// { "db.system.name": "postgresql", "db.namespace": "orders",
// "db.collection.name": "users" }A field is in that projection only when suss's value is the value a span gets, so a consumer joining a summary against a trace compares strings and keeps no table of its own.
What stays out of the projection
Three kinds of field stay out of the projection, and every protocol module says which case it is in:
- The conventions never named it. A secondary index (
storage.accessPath) is one. Agraphql-resolveris another: the conventions describe the operation a client sent, not the resolver the server ran for one field of it. - suss supplied the value because no source stated one.
storage.scopeis"default"when nothing said which database, andrest.methodis"*"for a route that responds to every method. A span says neither, so emitting them would produce nothing but mismatches. - The same thing under a different string.
service.nameandcloud.resource_idboth point at the deployable that aruntime-configboundary belongs to, butinstanceNameis the deployment template's logical id, which is neither of those strings.
Whole protocols are suss's own as well. A function-call boundary is a call that never leaves the process, and a metric has a system and a type string the conventions never covered. A metric's measurement words in its contract metadata do come from OpenTelemetry, though from the metrics data model rather than an attribute registry: histogram for a bucketed measurement, and gauge, delta, cumulative for what one measurement covers. The conventions also miss several stores and buses: s3, gcs, r2, d1 and cloudflare-kv on one side, eventbridge, bullmq, nats and the Cloudflare triggers on the other. transport is a suss axis too, since a span does not report the wire behind an AWS SDK call.
A boundary that nothing crosses at run time never gets a span, so no convention outside suss has had to give it a name, and suss reads plenty of those. When you add a protocol, fill in its semconv, even when it is empty. The compiler requires it.
Dispatching on semantics
Each protocol is one module under @suss/ir-core's semantics/ directory, which defines its schema and its BoundaryBehavior together. The registry composes the modules into the Semantics union and the runtime lookup. Each behavior defines three things:
identityKey: the name a reader sees and a suppression targets ("GET /users/{id}","* /api/users","bus:aws_sqs order.placed"), or null when the source never stated one.pairingKey: the bucket that pairing groups by. It contains what both sides always know. A REST bucket contains the path alone, soGET /usersand* /usersboth land inrest /users.sidesAgree: checks the part the bucket left out.GETagrees withGETand with"*".default#order.placedagrees withorder.placedand disagrees withstaging#order.placed.
boundaryKey, pairingKey, and semanticsAgree in packages/ir-core/src/boundaryKey.ts are thin lookups over the registry. Adding a protocol means adding one module and one line in each registry list. A compile-time check fails when the two lists differ. The definitions ship with ir-core rather than with packs, because a published summary has to mean the same thing to a reader who never installed the pack that wrote it.
The passes that pair through their own machinery still do so: pairGraphqlOperations walks selection sets, and the per-domain checker modules (message-bus/, runtime-config/, storage/) filter by semantics.name. identityKey returns null for the runtime-config and storage variants, and message-bus keys and pairs through the generic pass as well.
Grounding a deploy-time name
A queue URL, a function name and a table name only exist once a stack is deployed, so the source reaches them through a variable and the template says what that variable is. Two more behaviors cover that:
nameReference: where to look up this boundary's name, or null when the source stated a name outright.groundName: the same boundary with the deployment's values filled in. REST puts a base URL back into the front of a path and drops a host that can be the project's own, unit-invocation swaps the callee for the resource the template points the variable at, and storage swaps the container for the string the deployment sets it to.
groundBinding in packages/ir-core/src/boundaryKey.ts is the lookup, and deploymentOf in @suss/behavioral-ir supplies the values. Everything that reads a boundary name for a person to see goes through the pair: the pairing pass, suss infer intent when it writes a document, and checkIntentAgreement when it reads one back. If only one of them took that step, the drafter would write a name the checker then disagreed with.
A run with no template in it resolves nothing, and neither does a run where two deployments of the same code set a variable differently. Both leave the boundary spelled the way the source spells it, and a document drafted from that run spells it the same way.
Metadata namespaced by semantics
Two sets of keys are already namespaced this way: metadata.http.{declaredContract, bodyAccessors, statusAccessors} for REST, and metadata.graphql.{declaredContract, schemaSdl} for GraphQL. The same naming convention applies across all semantics:
metadata.http.*, REST-scopedmetadata.graphql.*, GraphQL-scopedmetadata.sourceDocument.label: which document a summary was read out of, so summaries from one document can find each other. The GraphQL schema goes on the summary standing for the schema document, and its resolvers reach it through the shared labelmetadata.runtimeContract.*: runtime-config env var listsmetadata.storageContract.*: the field declarations for a storage container, and whether they are the complete set
Keys outside those namespaces are semantics-neutral (e.g. metadata.derivedFromWrapper from the wrapper-expansion post-pass). metadata.codeScope is semantics-neutral too: it says which source files a boundary covers, for any boundary whose provider and consumer need pairing that takes scope into account.
Future semantics variants
One variant is still to come:
{ name: "kafka-message"; topic: string }for Kafka topics beyond themessage-busvariants already covered by SQS/BullMQ/NATS.
A lambda-invoke variant was planned here and unit-invocation shipped in its place. Keying on a function name plus a qualifier would have made one identity per cloud and per published copy of a function, and what both sides of an invoke can spell is the platform and the name.
A new variant ships as another member of the discriminated union, and the variants already there do not change. Anything that would move REST's method and path out of semantics needs a variant of its own.
Boundaries compose
A production invocation often crosses more than one boundary. An HTTP client hitting Route53 → ALB → API Gateway → Lambda → Express handler crosses five boundaries, one composed on the next. Each hop has a provider side (what it exposes upstream) and a consumer side (what it calls downstream), and pairing two sides at a time handles each hop the same way today's REST pairing does.
The plan is to model each cloud-infra component as a separate code unit with its own BoundaryBinding on each side. For that to work, three things need to be true:
- Binding identities need to be composable. An API Gateway binding's
path: "/users/*"needs to match against an Express server's mount point. Today's exact-path matching doesn't handle wildcards or prefix rewrites across hops. - Contract packs need to emit both sides. Today's
contract-aws-apigatewayonly emits the public-facing side. Richer versions need to emit what API Gateway calls downstream as well, so the checker can pair the downstream side of one hop against the upstream side of the next. - Transformations between hops have no IR representation. Path rewrites, header additions, and changes to the body's structure between a unit's input binding and its output binding are the missing IR primitive. A proxy that strips a path prefix before forwarding changes the binding identity across the hop, and there is currently no way to declare that mapping.
A binding.role: "proxy" | "handler" | "transform" enum was considered and rejected. Transformation is a continuum, so it is better modelled as a transformation descriptor, such as path-rewrite rules or a list of headers to add, than as a category enum.
Assembling multi-hop chains belongs in the query layer. Once pairing works two sides at a time over binding identities detailed enough to describe each hop, walking a chain is graph traversal over the pairing results, which an MCP tool or a query CLI can do over the summary store. The work ahead is in the IR: a transformation descriptor, and contract packs that emit the consumer side of each infrastructure component.
What's shipped vs what's deferred
Shipped:
BoundaryBindinghastransport,semantics, andrecognitionas top-level fields.@suss/behavioral-irexports eleven binding builder helpers, and every pack and contract source builds its bindings through them.- Ten
semanticsvariants:rest,function-call,graphql-resolver,graphql-operation,runtime-config,storage,message-bus,metric,unit-invocation,io. - Metadata namespaced under
metadata.http.*andmetadata.graphql.*, withmetadata.runtimeContract.*andmetadata.storageContract.*for the newer semantics. - Checker modules for HTTP/REST, GraphQL (contract agreement and operation pairing), message-bus, storage, runtime-config, unit-invocation, and Storybook stories.
boundaryKeydispatches onsemantics.name. Summaries without a matchable key go tounmatched.unpairable, and each entry says why.
The dispatch registry has also shipped. Each variant declares its behavior (identityKey, pairingKey, sidesAgree) in its own module under packages/ir-core/src/semantics/, and registry.ts composes them with a compile-time completeness check.
Deferred:
- A
kafka-messagesemantics variant. - Composable binding identities and transformation descriptors for multi-hop infra chains, described in the section above.
- Operation-level consumer-side GraphQL pairing beyond root-field selection (nested type checking via the SDL is wired; full variable-type comparison against resolver arguments is not).
Related decisions
See also:
- Four decisions built the model on this page: the checker reads pack metadata rather than hardcoding frameworks, a summary states its
BOUNDARY_ROLE, packs supply the accessor that reads a status off a response, and the three layers on this page were written down. The status design record keeps the log as the work happens. - Architecture, the current package dependency graph and protocol assumptions.
- Pack patterns, how packs describe recognition today, and the extension points for when semantics becomes a top-level axis.
- Contract sources, where boundary layering through the AWS API Gateway contract reader is a precursor to explicit boundary composition.