Skip to content

Kinds of contract

"Contract" is the most overloaded word in suss. At one boundary it means three different things, and the severity of a finding depends on which of the three it was compared against.

Take an invoice endpoint. The API document declares that GET /invoices/:id returns 200, 404 or 500. The handler never produces a 500, and somebody in the web app wrote a retry path for one anyway. The panel that renders the invoice treats every 200 as a live invoice, and the handler returns voided invoices as 200 too. Each of those statements is true on its own. The trouble shows up when you put two of them side by side.

The three contracts, in one run

Here is that endpoint as three files. The contract is a ts-rest router, so the declaration lives in the repository beside the code. An OpenAPI document read with suss contract --from openapi does the same job.

ts
// src/contract.ts
export const contract = c.router({
  getInvoice: {
    method: "GET",
    path: "/invoices/:id",
    responses: {
      200: c.type<{ id: string; total: number; state: string }>(),
      404: c.type<{ error: string }>(),
      500: c.type<{ error: string }>(),
    },
  },
});
ts
// src/handler.ts
export const router = s.router(contract, {
  getInvoice: async ({ params }) => {
    const invoice = await findInvoice(params.id);

    if (!invoice) {
      return { status: 404 as const, body: { error: "not found" } };
    }

    if (invoice.voidedAt) {
      return {
        status: 200 as const,
        body: { id: invoice.id, total: 0, state: "void" },
      };
    }

    return {
      status: 200 as const,
      body: { id: invoice.id, total: invoice.total, state: "open" },
    };
  },
});
ts
// src/invoicePanel.ts
export async function loadInvoice(id: string) {
  const response = await fetch(`/invoices/${id}`);

  if (response.status === 200) {
    const invoice = await response.json();
    return { total: invoice.total };
  }

  throw new Error("could not load invoice");
}

Read both sides with suss extract -f ts-rest -f fetch -o summaries/all.json, which writes three summaries, then print them:

bash
suss inspect summaries/all.json
src/handler.ts
└─ GET /invoices/{id}  (ts-rest handler | line 8)
     Contract: 200, 404, 500
       if  !findInvoice()
         -> 404 { error }
       elif  findInvoice().voidedAt
         -> 200 { id, total, state }
       else
         -> 200 { id, total, state }
           + src/db.findInvoice →

       !! Declared response 500 is never produced by the handler

src/invoicePanel.ts
└─ GET /invoices/{id}  (fetch client | line 1)
       if  fetch().status === 200
         -> return { total }
       else
         -> throw Error
           + fetch
           + response.json

src/db.ts
└─ findInvoice  (reachable library | line 5)
       -> return Invoice (src/db.ts)

3 summaries.

All three contracts are in that output. Contract: 200, 404, 500 is the declaration, read off the router. The lines under the handler are the branches it takes, and the lines under the client are the ones the panel depends on. check compares them pairwise:

bash
suss check --dir summaries/
Compared 1 boundary.

  1 boundary had nothing to pair with, so nothing was checked across it.
  Run the same command with --all to list them.

────────────────────────────────────────────────────────────
[ERROR] providerContractViolation
  Declared response 500 is never produced by the handler
  provider: src/handler.ts::getInvoice (src/handler.ts:8)
  consumer: src/invoicePanel.ts::loadInvoice (src/invoicePanel.ts:1)
  boundary: ts-rest (http) GET /invoices/:id
────────────────────────────────────────────────────────────
7 findings: 1 error, 6 warning, 0 info

Not shown: 4 unhandledProviderCase (warning), 2 consumerContractViolation (warning). Run the same command with --all to see them.

suss met a call it could not follow in one unit, of 3, so that one is described in part. `suss inspect` says which calls.

The 500 is an error and the other six findings are warnings. The difference comes from what each side of the comparison is: a specification, an observation or a derivation.

Three kinds of truth

Any artifact about code tells you one sort of thing, and which sort it is decides how suss treats it.

Kind of truthWhat it tells youExamplesCompleteness
Specificationwhat should happenOpenAPI, TypeScript interfaces, Storybook stories, Prisma schemas, CloudFormation templatesUnder-specified. Declares what is allowed, and rarely when each case fires
Observationwhat did happen, onceSnapshots, Pact recordings, Playwright tests, production logsPoint samples. Covers only what was tested
Derivationwhat the code does, across all pathsA suss BehavioralSummaryComplete over paths, limited by analyzer fidelity

The BehavioralSummary is the only artifact suss produces itself, and it fills the derivation row. Every declared contract and every contract source suss reads is a specification or an observation.

The findings come from comparing one kind against another:

  • Derivation ⊄ Specification: the code takes a path the specification never declares. The handler produces a 500 that OpenAPI does not mention.
  • Specification ⊄ Derivation: the specification declares a case the code cannot reach. That is the error in the run above.
  • Observation ⊄ Derivation: something happened that the code should not be able to produce. This is rare, and it is usually a bug.
  • Derivation ⊄ Observation: the code reaches paths no test covered. That tells you where your coverage is thin.

The three contracts at a boundary

Every boundary has all three of these, whether or not anyone writes them down. Cross-boundary checking lists which checker function fires for which comparison.

The declared contract is the one a person wrote, and a project does not have to have one. It might be ts-rest responses or an OpenAPI schema, and it declares which statuses and body structures are supposed to exist. This is a specification, and it is what most tools check against. Because a person wrote it, it can be wrong, incomplete or out of date. Where it does exist, it is the one artifact the provider team and the consumer team both point at. In the run above it is Contract: 200, 404, 500, read straight off the router.

The provider's inferred contract is a derivation. It is the set of transitions the handler produces: under condition A it returns X, and under condition B it returns Y. It covers more ground than the declaration. It separates sub-cases inside one status code, so 200 is { total: 0, state: "void" } when invoice.voidedAt is set and { total, state: "open" } otherwise, where the declaration collapses both of those into a single entry with one body. It also turns up gaps, such as a declared 500 that no branch produces, or a 418 the declaration never mentions.

The consumer's inferred contract is the other derivation. It is which status codes the caller branches on, which body fields it reads and which conditions it tests on the response. Nobody writes this one down. The only place it exists is the consumer's own source, and that is where suss reads it from. if (response.status === 200) means the consumer expects 200 and, in the run above, nothing else. Reading invoice.total means it depends on total being there. Had the code tested if (invoice.state === "void"), suss would have recorded that the consumer separates a sub-case by a body field. This consumer does not test the body, so the run reports the two 200s as one.

Contract shapes

The three contracts above are all HTTP. In other domains, contracts come in more shapes than a schema, and a large domain usually uses several of them. Each shape is one of the three kinds of truth.

ShapeWhat it declaresKind of truth
Schemawhat types cross the boundary: OpenAPI, ts-rest responses, GraphQL SDL, Prisma schemas, Avro and Protobuf, database DDLspecification
Examplesone valid interaction: Pact contracts, HAR captures, fixture files, curl examples in docsobservation
Testswhat should be true when X happens: Playwright specs, RTL component tests, supertest suitesobservation
Snapshotswhat the output looked like for one input: Jest and Vitest .snap files, visual-regression baselinesobservation
Designwhat the output should look like or do: Figma files, design tokens, accessibility specificationsintent

Everything suss reads today is schema-shaped, across the HTTP, GraphQL, AppSync, message-bus, storage and component domains. Point suss contract --from <source> at one and you get summaries in the same form extract produces; Contract sources lists the readers that ship.

The other shapes have no reader. The one observation that reaches a summary comes from suss corroborate --experimental, which runs your code. It generates inputs that satisfy a claim's own conditions, runs the handler on them, and records the verdict in confidence.corroboration as observed, as refuted along with the input that disagreed, or as untested. Design shapes are left out on purpose. Design files rarely live in the repository, and integrating with those APIs would cost more than the result is worth.

Team-authored intent is a kind of truth of its own, with an artifact stream separate from the contract sources. Check against your intent covers the two document kinds and the commands that read them.

Severity follows the kind of truth

A finding's severity comes from the kinds of truth being compared, not from the file format the contract arrived in:

  • A derivation violates a specification: error. The code has drifted from what it promised. That is the providerContractViolation in the invoice run, where the router promises a 500 and no branch produces one.
  • An observation violates a specification: warning. Something happened that the specification said could not happen.
  • An observation is missing for a specification case: info. That is a gap in coverage and not a bug.
  • Two specifications disagree: warning. Somebody has to reconcile them. This is the contractDisagreement finding.
  • Two derivations disagree: warning. That is why the other six findings in the invoice run are warnings. Whether an uncovered status is a bug depends on intent the code does not state, so the run reports it and leaves the call to you.

The same rule assigns intent severities: a derivation that violates declared system intent is an error, and a derivation that exceeds open intent is info.

Released under the Apache-2.0 License.