Boundary semantics
The IR's BoundaryBinding has all three layers of a boundary description as separate fields: what bytes travel (transport), what the participants think they're doing (semantics), and how a particular library expresses that in source code (recognition).
Nine semantics variants ship today: rest, function-call, graphql-resolver, graphql-operation, runtime-config, storage, message-bus, metric, and unit-invocation, each as its own module under packages/ir-core/src/semantics/. To find out whether a protocol already works, jump to What's shipped vs what's deferred. The rest is the model those nine variants share.
The three layers
A "boundary" in suss is three things at once, which the IR stores as sibling fields on BoundaryBinding:
Transport
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 is mostly beside the point for cross-boundary checking. It matters for tooling concerns (authentication, retries, transport-level errors, TLS, timeouts) but not for "does the provider's contract match what the consumer reads?"
Semantics
What the participants think they're doing. This is the layer cross-boundary checking actually cares about.
- 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
How a particular library expresses a given semantics in source code. This is what today's PatternPack already describes and what responseSemantics partially captures.
- 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.
Recognition is a per-pack concern. Semantics says what the pack is describing in the end, not what its recognition 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[] }
| { 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; 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 };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. 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.
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 lives 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).
Whether a field the code touches can be called unknown is a property the provider declares, not something the store's name implies: metadata.storageContract.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.
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: the event-source mapping is what 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.
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 that platform calls 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 a spelling of that name and not the identity, since it has an account and a region in it and a dev ARN and a prod ARN name 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, scope, container /* string | null */, accessPath?, transport? })
messageBusBinding({ recognition, messageBus, channel /* string | null */ })
metricBinding({ recognition, metricSystem, metricType /* string | null */ })
unitInvocationBinding({ recognition, deploymentTarget, instanceName /* 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; a trace says what it did reach. Comparing them is what neither static analysis nor observability does today, and it 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 writes their word, and the rest of the vocabulary is ours. A span stays a record of one execution and a summary stays a statement about a unit, and suss does not emit traces.
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 only ever produce a mismatch. - 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, empty included, and the compiler makes you answer the question.
Dispatching on semantics
Each protocol is one module under @suss/ir-core's semantics/ directory: its schema and its BoundaryBehavior live together, and the registry composes the modules into the Semantics union and the runtime lookup. Each behavior has to answer three questions:
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: decides 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 this boundary's name says to go and ask, or null when the source stated a name outright.groundName: the same boundary with what the deployment fills in put in. REST puts a base URL back into the front of a path, 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 have already moved there: 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 the thing both sides of an invoke can spell is the platform and the name.
Each one ships as another discriminated-union variant, and none of them reshape the variants already there. 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 the right way to model it is a transformation descriptor (path-rewrite rules, header-add list, etc.) rather than 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 ten binding builder helpers, and every pack and contract source builds its bindings through them.- Nine
semanticsvariants:rest,function-call,graphql-resolver,graphql-operation,runtime-config,storage,message-bus,metric,unit-invocation. - 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 shipped since the list above was first written. 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 below got 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.