Skip to content

Intent format

An intent document is a file your team writes and commits, saying what a piece of the system should do. suss check --intent <dir> reads every *.intent.yaml and *.prd.yaml under that directory and compares each one against the summaries of what the code does. .yml and .json work as well, and the kind at the top of a file decides which shape it has, whatever the file is called.

There are two kinds. Boundary intent (kind: boundary) says what one boundary should do: every outcome it can produce, what each one turns on, and what each one sends back or does. An engineer writes it, and the checker compares it against the code. A PRD (kind: prd) says what should happen for the person using the feature, as scenarios in that person's terms. A scenario can link to an outcome a boundary document declares, and that link ties the words to the code.

Check against your intent walks through writing both from a codebase that already exists, and the findings catalog says what the checker reports when the two disagree.

Boundary intent

FieldRequiredWhat it means
kindyesboundary.
nameyesWhat the document is called. A PRD scenario links to an outcome of it as <name>.<outcome-id>.
purposeyesWhat the boundary is for, in your words.
audienceyesWho calls the boundary and depends on what it does.
sourcenoWhere the document came from. Defaults to author.
boundaryyesWhich boundary in the code the document is about.
transitionsyesEvery outcome the boundary can produce, at least one.

Nothing else can appear at the top level. Write transition: for transitions: and suss reports the key and stops. The same goes inside a transition and inside a scenario.

Here is a whole document, the worked REST example from design/proposals/intent-layer-examples/fastify-users:

yaml
kind: boundary

name: users-lookup
purpose: GET /users/:id retrieves a single user record.
audience: web-client
source: author

boundary:
  transport: http
  semantics: rest
  method: GET
  path: /users/:id

transitions:
  - id: missing-id
    when: id parameter is empty
    response:
      status: 400
      body:
        properties:
          error: { type: string }

  - id: not-found
    when: user with the requested id does not exist
    response:
      status: 404
      body:
        properties:
          error: { type: string }

  - id: found
    when: user exists and is not an admin
    response:
      status: 200
      body:
        properties:
          id: { type: string }
          name: { type: string }
          role: { type: string }

The boundary block

semantics says which sort of boundary this is, and the rest of the block follows from it. A GraphQL field, a runtime-config read and a metric have no block yet.

A block can leave out what the checker pairs on. The checker reports it as unkeyableBoundary and puts it under the unchecked count, so you can write intent ahead of the code.

semantics: rest

FieldRequiredWhat it means
semanticsyesrest.
methodyesThe HTTP method the route handles.
pathyesThe route path, written the way the framework declares it, so an Express route keeps :id.
transportnohttp, which is also the default.
receivesnoThe parts of the request the boundary depends on.

semantics: function-call

FieldRequiredWhat it means
semanticsyesfunction-call.
packagenoThe package name, when the boundary is something a package publishes.
exportPathnoThe path to the export inside the package: the sub-path, then any nested names.
modulenoThe repo-relative module path, when the boundary is a unit inside this repository.
exportNamenoThe name the module or package exports the function under.
transportnoDefaults to in-process.
receivesnoThe arguments the boundary needs, by parameter name.

The checker pairs a function-call boundary on package and exportPath. A document that gives a module and an exportName instead describes a boundary inside one package, which has no key yet, so it goes unchecked.

semantics: message-bus

FieldRequiredWhat it means
semanticsyesmessage-bus.
messageBusyesWhich bus the message travels on: aws_sqs, aws.sns, kafka, and the rest of the set in boundary semantics.
channelnoThe queue or topic the message travels on. Defaults to null, and the checker pairs on it.
receivesnoThe fields of the message body the consumer depends on.

semantics: storage

FieldRequiredWhat it means
semanticsyesstorage.
storageSystemyesWhich store this is: postgresql, aws.dynamodb, s3.
scopenoThe ORM, schema or deployment scope the container is in. A setup with one database uses default, which is also the default value.
containernoThe table, bucket, collection or index. Defaults to null.
accessPathnoA secondary way into the container, such as a DynamoDB index or an Elasticsearch alias. Defaults to null.
receivesnoThe fields the access is handed.

A store is the one boundary where filling the fields in does not make it pairable, because a container name can be a pattern that only a caller or the deployment settles. To say what a store is for, put - writes: aws.dynamodb:Invoices on an outcome of the boundary that touches it, and the checker compares that against the accesses on that boundary.

semantics: unit-invocation

FieldRequiredWhat it means
semanticsyesunit-invocation.
deploymentTargetyesWhat sort of deployed thing this is: lambda, ecs-task, container, k8s-deployment or worker.
instanceNamenoThe name the deployment medium knows the unit by, such as a CloudFormation logical id. Defaults to null, and the checker pairs on it.
receivesnoThe fields the unit is handed.

What the boundary receives

A receives block lists the fields the boundary is handed. A function-call, message-bus, storage or unit-invocation boundary writes one line per field, keyed by name, and a dot reaches inside one:

yaml
receives:
  provider: { type: object, required: true }
  options.stream: { type: string }

A REST boundary has a section per part of the request, because a sender fills the four parts separately:

yaml
receives:
  headers:
    x-tenant-id: { type: string, required: true }
  query:
    dryRun: { type: boolean }
  params:
    id: { type: string, required: true }
  body:
    type: object
    properties:
      note: { type: string }

Each field takes:

FieldRequiredWhat it means
requirednoWhether the boundary needs this field. Defaults to false.
typenoOne of string, integer, number, boolean, null, unknown, array and object.
itemsnoThe shape of an element, when type is array.
propertiesnoThe fields under it, when type is object.

Naming a field is a complete declaration on its own, so consumer: {} says the field is there and says nothing more about it. The block lists the fields you want checked, and it can leave the rest out. A field the code reads that the block does not list is reported at info.

Transitions

Each transition describes one outcome, and a document has one for every outcome the boundary can produce.

FieldRequiredWhat it means
idyesThe outcome's name. A PRD scenario links to it as <intent-name>.<id>. It is free-form, and it is where you write what the outcome means.
whenyesWhat has to hold for this outcome.
responsenoThe outcome sends an HTTP response.
returnsnoThe outcome returns a value to its caller.
throwsnoThe outcome raises an error.
resultsnoThe effects the outcome has.

A transition ends one way, so it takes at most one of response, returns and throws. It also has to say something, so it needs one of those three or a results list.

response takes a status between 100 and 599, required, and an optional body. returns takes an optional body. throws takes an optional errorType, the name of the error class.

A body takes type, plus items when it is an array and properties when it is an object. properties: with no type: above it is shorthand for an object. required inside a body shape is the list of property names that have to be there. The required on a receives field is a boolean, and the two are unrelated.

when

when is either one sentence or a list of clauses. A clause says which subject it is about, says at most one thing about that subject, and can narrow it with where:

yaml
when:
  - reads: aws.dynamodb:Invoices
    finds: something
    where: settledAt is missing
KeyWhat it means
reads, writes, invokesThe subject is a boundary, written the way suss ask writes one. A clause takes one of these three or input.
inputThe subject is something the caller sent, written as the path it arrived on.
findsWhat a lookup came back with: nothing or something.
isWhat state the value was in: set, missing, null, a string.
equalsThe value it was equal to.
hasA property it had.
whereNarrows the clause with whatever the guard said about a deeper read of the same result.

A clause says at most one of finds, is, equals and has. A guard that maps to none of this stays the sentence you wrote, and a whole when written as one string is valid.

A fall-through branch states its own condition. Pointing at the branches above it would change what the branch claims as soon as somebody inserts a transition over it.

results

results is a list of what the outcome does, in the verbs suss ask asks with:

yaml
results:
  - writes: aws.dynamodb:Invoices
    fields: [email, phone]
  - invokes: unit:lambda ArchiveWorker
KeyRequiredWhat it means
reads, writes, invokesone of the threeThe boundary the outcome touches, written the way every report prints it and suss ask takes it.
fieldsnoThe columns the access touches.
bynoWhat the access picks the item out by. One name or a list of them.

suss ask "what writes aws.dynamodb:Invoices" is the question and a results line is the assertion, spelled the same way. Where a line has a fields list, the checker requires that the access cover every column on it.

A PRD

FieldRequiredWhat it means
kindyesprd.
titleyesWhat the document is called.
purposeyesWhat the feature is for, in your words.
audienceyesWho the feature is for.
sourcenoWhere the document came from. Defaults to author.
scenariosyesThe situations the feature covers, at least one.

Each scenario takes:

FieldRequiredWhat it means
whenyesThe situation, in your words.
expectyesWhat should happen, in your words.
titlenoA short name for the scenario.
linknoThe boundary-intent outcomes this scenario is about.

The PRD that goes with the boundary document above, whole:

yaml
kind: prd

title: User profile lookup
purpose: |
  The client app can fetch a user's profile information by id. The
  endpoint should distinguish between missing input, unknown users,
  and admin users (who get an enriched profile).
audience: web-client

scenarios:
  - when: a request arrives with a known user id
    expect: the caller receives the user's profile
    link: users-lookup.found

  - when: the request omits the id parameter
    expect: the caller is told the id is required
    link: users-lookup.missing-id

  - when: the id doesn't match any record
    expect: the caller is told the user wasn't found
    link: users-lookup.not-found

A link is <intent-name>.<outcome-id>: the name of a boundary document, a dot, then the id of one of its transitions. users-lookup.found points at the transition with id: found in the document named users-lookup. One link is a string and several are a list:

yaml
link:
  - order-intake.acknowledged
  - order-intake.queued-for-processing

A scenario can have no link. The words read on their own, and nothing has tied them to an outcome yet. The checker reports that as unlinkedScenario at info. A link to an outcome nothing declares is danglingScenarioLink at warning. A link to a name that two boundary documents share is ambiguousScenarioLink, also at warning.

Where a document came from

source takes one of three values, and both document kinds have the field.

ValueWhat it means
authorSomebody wrote the document.
inferredsuss infer drafted it from the code and nobody has been through it.
inferred, curatedsuss infer drafted it and somebody has been through it.

The checker reads source to decide how loudly to report. A finding against bare inferred intent is downgraded one level, because nobody has confirmed the declaration yet. Curating restores the full severity.

Curating a boundary document means writing the purpose and audience that suss infer left blank, renaming the outcome ids to what your team calls them, and setting source to "inferred, curated". Curating a PRD means writing the when and expect of every scenario. A draft with a blank still in it does not satisfy the schema, so a run over the folder refuses it and says which files are waiting.

The JSON Schema

@suss/intent-ir publishes intent-doc.schema.json, generated at build time from the same zod schemas the CLI parses with, so the two say the same thing. A tool in any language can validate a document against it.

To have an editor check a document as you type, put a comment on its first line saying where the schema is. The YAML language server reads that comment, and VS Code and Neovim both run it:

yaml
# yaml-language-server: $schema=https://raw.githubusercontent.com/nimbuscloud-ai/suss/main/packages/intent-ir/schema/intent-doc.schema.json

kind: boundary

name: users-lookup
purpose: GET /users/:id retrieves a single user record.
audience: web-client

Every field has a description in the schema, so hovering over one says what it means, and completion offers the keys the document kind takes.

The schema is generated from the authoring side of the zod schemas, so a field with a default is optional in it, the way it is for somebody writing the file by hand. A test in @suss/intent-ir runs both the schema and the parser over every intent document in this repository and fails when the two disagree.

Released under the Apache-2.0 License.