Documentation for v2.9.0. Read the latest, v2.12.0.

Developer

A team's story

One team, start to finish. The payments Producer publishes its first spec, the orders Consumer pins it, a forgotten version bump and a breaking change are refused, a snapshot is iterated and promoted, a release is cut, the catalog is pushed, and an AI assistant asks about the result. Every exchange below was recorded against a fresh 2.6.1 instance by scripts/record-walkthroughs.sh; the graph on the right of each step is the catalog as it stands at that point.

How to read this page

Use Previous and Next, the step chips, or the arrow keys; #step-N in the address opens a step directly. Each step says what the team does, shows the recorded answer (an HTTP exchange, a client transcript or a screenshot), and links to the chapter and the reference entry it uses. Tokens are redacted and the instance's address is shown as https://sanshain.corp; everything else is as the service answered it.

The payments team's catalog, step by step The payments Producer with its versions 1.0.0, 2.0.0 and 2.1.0, the orders Consumer pinning POST /payments at 1.0.0, the two rejected provides, the release branch around them, the two peripheral resources from the catalog push, the draft checked by the validator and the AI client asking over MCP. The parts that exist at the current step are drawn; the ones that changed are highlighted. sanshain-branch release-2026.10 orders Consumer POST /payments pinned at 1.0.0 payments Producer 1.0.0 snapshot GA 2.0.0 GA 2.1.0 GA diff and history 1.0.0 changed → 409 1.1.0 breaking → 409 payments-db bank-gateway from catalog-info.yaml 2.2.0 draft validated locally, not published Claude Code read-only token describe_service over MCP
The catalog at this step.

1. A first sanshain.yaml and a snapshot#

The payments team writes the one file every client reads and keeps the version where it belongs: in the spec. Their trunk pipeline runs cargo sanshain provide --trunk. Nothing says snapshot; that is the default.

payments/sanshain.yaml

serviceName: payments
catalogInfo: catalog-info.yaml
provides:
  - file: openapi.yaml        # version read from info.version

payments/openapi.yaml

openapi: 3.0.3
info:
  title: Payments
  version: 1.0.0
paths:
  /payments:
    post:
      summary: Create a payment
      responses:
        "201":
          description: Created
  /payments/{id}:
    get:
      summary: One payment
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      responses:
        "200":
          description: OK

The client, in the pipeline

# recorded with curl; the cargo-sanshain invocation is:
$ cargo sanshain provide --trunk

What the catalog holds now

GET /producers/payments/versions HTTP/1.1
Authorization: Bearer san_…

HTTP/1.1 200 OK
content-type: application/json
content-length: 369

[
  {
    "api_type": "openapi",
    "version": "1.0.0",
    "stability": "snapshot",
    "content_hash": "sha256:c79fec9c98fc8f883b24e080ef5f3a19644c5200c82362f06122186585d4574b",
    "provided_by": "root",
    "created_at": "2026-10-18T16:07:53Z",
    "updated_at": "2026-10-18T16:07:53Z",
    "last_required_at": null,
    "endpoint_count": 2,
    "expires_at": "2026-11-17T16:07:53Z",
    "trunk_provided_at": "2026-10-18T16:07:53Z"
  }
]

What to notice

  • The version 1.0.0 came from info.version; the client sent only the stability.
  • Two endpoints were split out of the document (endpoint_count: 2).
  • A snapshot has an expires_at: it ages out when nobody provides or requires it (the operator sets the days). trunk_provided_at says trunk CI was here.

Go deeper

sanshain.yaml: provides · Clients: cargo-sanshain · Getting started: the first provide · POST /provide · GET /producers/{producername}/versions

2. The same number as GA#

The team is happy with 1.0.0 and releases it. Same document, same number, "stability": "ga"; recorded by hand, to show the exchange the clients make with --ga.

# recorded with curl; the cargo-sanshain invocation is:
$ cargo sanshain provide --ga --trunk
POST /provide HTTP/1.1
Authorization: Bearer san_…
Content-Type: application/json

{
  "producername": "payments",
  "stability": "ga",
  "openapi_yaml": "openapi: 3.0.3\ninfo:\n  title: Payments\n  version: 1.0.0\npaths:\n  /payments:\n    post:\n      summary: Create a payment\n      responses:\n        \"201\":\n          description: Created\n  /payments/{id}:\n    get:\n      summary: One payment\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema: { type: string }\n      responses:\n        \"200\":\n          description: OK",
  "trunk": true
}

HTTP/1.1 202 Accepted
content-type: application/json
content-length: 189

{
  "version": "1.0.0",
  "stability": "ga",
  "content_hash": "sha256:c79fec9c98fc8f883b24e080ef5f3a19644c5200c82362f06122186585d4574b",
  "changes": {
    "inserts": 0,
    "updates": 0,
    "deletes": 0
  },
  "promoted": true
}

What to notice

  • "promoted": true: the snapshot became the GA version in place, nothing was stored twice.
  • changes are all zero because the bytes are identical; the number is now claimed for good.
  • Publishing GA needs the releaser role; the token's owner has it.

Go deeper

API lifecycle: going GA · CI integration: stability from the pipeline · Glossary: promotion · POST /provide

3. orders pins an endpoint and fetches its snippet#

The orders team needs one operation. Their sanshain.yaml names the Producer, the exact version and the endpoint; the JavaScript client fetches the snippet and writes it where the code generator expects it. Then the same require by hand, to see the headers.

The client, in the orders pipeline

# recorded with curl; the sanshain-js invocation is:
$ sanshain --trunk require

The same require by hand

GET /require?consumername=orders&producername=payments&version=1.0.0&path=/payments&method=POST&trunk=true HTTP/1.1
Authorization: Bearer san_…

HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
etag: "3251ba1a99d001bd0ce58d03f79fcd014c43e49557c45c13005d6cf3d9d117b9"
x-sanshain-resolution: served
x-sanshain-version: 1.0.0
x-sanshain-stability: ga
content-length: 181

openapi: 3.0.3
info:
  title: Payments
  version: 1.0.0
paths:
  /payments:
    post:
      summary: Create a payment
      responses:
        '201':
          description: Created
The dependency graph: the orders Consumer with an edge to the payments Producer
The graph right after the require, taken at this step: orders depends on payments.

What to notice

  • The snippet is a complete OpenAPI document that holds only POST /payments.
  • x-sanshain-stability: ga says what answered; x-sanshain-resolution: served that the pin resolved. A pin to a version that does not exist is 404, a version that exists but lacks the endpoint is 410; there is no fallback.
  • The etag lets the next build skip generation when nothing changed; the clients send it for you.
  • trunk=true records the pin in the main graph, the one a release will be cut from.

Go deeper

sanshain.yaml: requires · Clients: JavaScript · Concepts: pins · GET /require

4. A forgotten bump is refused, with the number to use#

Payments adds a capture endpoint and forgets to touch info.version. The release pipeline provides the changed document as GA under 1.0.0; the exchange is recorded by hand.

# recorded with curl; the cargo-sanshain invocation is:
$ cargo sanshain provide --ga --trunk
POST /provide HTTP/1.1
Authorization: Bearer san_…
Content-Type: application/json

{
  "producername": "payments",
  "stability": "ga",
  "openapi_yaml": "openapi: 3.0.3\ninfo:\n  title: Payments\n  version: 1.0.0\npaths:\n  /payments:\n    post:\n      summary: Create a payment\n      responses:\n        \"201\":\n          description: Created\n  /payments/{id}:\n    get:\n      summary: One payment\n      parameters:\n        - name: id\n          in: path\n          required: true\n          schema: { type: string }\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/capture:\n    post:\n      summary: Capture an authorised payment\n      responses:\n        \"200\":\n          description: OK",
  "trunk": true
}

HTTP/1.1 409 Conflict
content-type: application/json
content-length: 202

{
  "error": "version 1.0.0 of openapi 'payments' is GA and immutable, but the submitted content differs — did you forget to increment info's version? Publish as 1.1.0 instead",
  "proposed_version": "1.1.0"
}

What to notice

  • 409 Conflict, and proposed_version names the next number: a new endpoint is a minor change, so 1.1.0.
  • Nothing was stored. The fix is a one-line edit in the spec file, not a pull-merge-retry loop.
  • A pull-request pipeline catches this before the merge with "dry_run": true.

Go deeper

API lifecycle: the server proposes · CI integration: dry runs · Glossary: proposed version · POST /provide

5. A breaking change behind a minor bump is refused#

The team takes the hint, sets 1.1.0, and in the same change renames the path parameter of GET /payments/{id}. That removes a path every Consumer may pin. The release pipeline's exchange, recorded by hand:

# recorded with curl; the cargo-sanshain invocation is:
$ cargo sanshain provide --ga --trunk
POST /provide HTTP/1.1
Authorization: Bearer san_…
Content-Type: application/json

{
  "producername": "payments",
  "stability": "ga",
  "openapi_yaml": "openapi: 3.0.3\ninfo:\n  title: Payments\n  version: 1.1.0\npaths:\n  /payments:\n    post:\n      summary: Create a payment\n      responses:\n        \"201\":\n          description: Created\n  /payments/{paymentId}:\n    get:\n      summary: One payment\n      parameters:\n        - name: paymentId\n          in: path\n          required: true\n          schema: { type: string }\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/capture:\n    post:\n      summary: Capture an authorised payment\n      responses:\n        \"200\":\n          description: OK",
  "trunk": true
}

HTTP/1.1 409 Conflict
content-type: application/json
content-length: 199

{
  "error": "version 1.1.0 of openapi 'payments' is breaking relative to GA 1.0.0 but does not bump the major — publish as 2.0.0 instead: Path '/payments/{id}' was removed",
  "proposed_version": "2.0.0"
}

What to notice

  • Sanshain compared against the latest GA, 1.0.0, found a removed path, and asks for 2.0.0.
  • The message names the path, so the decision (rename, or keep the old one deprecated) is an informed one.

Go deeper

API lifecycle: the rules of a version line · CI integration: what an error says

6. 2.0.0 is accepted#

With info.version: 2.0.0 the same document goes through. This time the Go client publishes it, with the ga switch the release pipeline sets.

The client, in the release pipeline

# recorded with curl; the sanshain-go invocation is:
$ SANSHAIN_GA=true sanshain-go -trunk provide

The version line now

GET /producers/payments/versions HTTP/1.1
Authorization: Bearer san_…

HTTP/1.1 200 OK
content-type: application/json
content-length: 671

[
  {
    "api_type": "openapi",
    "version": "2.0.0",
    "stability": "ga",
    "content_hash": "sha256:e68af956da1af90a7196425b5be13004d405efbbe340e8690c56e73f34ae3339",
    "provided_by": "root",
    "created_at": "2026-10-18T16:08:02Z",
    "updated_at": "2026-10-18T16:08:02Z",
    "last_required_at": null,
    "endpoint_count": 3,
    "trunk_provided_at": "2026-10-18T16:08:02Z"
  },
  {
    "api_type": "openapi",
    "version": "1.0.0",
    "stability": "ga",
    "content_hash": "sha256:c79fec9c98fc8f883b24e080ef5f3a19644c5200c82362f06122186585d4574b",
    "provided_by": "root",
    "created_at": "2026-10-18T16:07:53Z",
    "updated_at": "2026-10-18T16:07:54Z",
    "last_required_at": "2026-10-18T16:07:54Z",
    "endpoint_count": 2,
    "trunk_provided_at": "2026-10-18T16:07:54Z"
  }
]
The payments Producer page: the catalog context card, the Webhooks card and the OpenAPI version line with GA and trunk badges
The Producer page, as the story ends: every version GA, trunk badges, the context from step 10.

What to notice

  • Nothing happened to orders: it pins 1.0.0, which stays served unchanged. Pins never move on their own.
  • The main graph will flag the pin as a major behind trunk; that is information, not a failure.

Go deeper

Clients: Go · Release graphs: the three streams · The web UI: Producers

7. A snapshot is overwritten, then promoted#

Work on 2.1.0 starts. The first provide stores a snapshot; a second provide with more content overwrites it under the same number; when the team is done, the ga switch promotes exactly that content. The three exchanges are recorded by hand:

# recorded with curl; the cargo-sanshain invocation is:
$ cargo sanshain provide            # twice, as the draft grows; then: cargo sanshain provide --ga --trunk

First snapshot

POST /provide HTTP/1.1
Authorization: Bearer san_…
Content-Type: application/json

{
  "producername": "payments",
  "stability": "snapshot",
  "openapi_yaml": "openapi: 3.0.3\ninfo:\n  title: Payments\n  version: 2.1.0\npaths:\n  /payments:\n    post:\n      summary: Create a payment\n      responses:\n        \"201\":\n          description: Created\n  /payments/{paymentId}:\n    get:\n      summary: One payment\n      parameters:\n        - name: paymentId\n          in: path\n          required: true\n          schema: { type: string }\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/capture:\n    post:\n      summary: Capture an authorised payment\n      responses:\n        \"200\":\n          description: OK"
}

HTTP/1.1 202 Accepted
content-type: application/json
content-length: 196

{
  "version": "2.1.0",
  "stability": "snapshot",
  "content_hash": "sha256:13be365243234cf17810a0bae193901868d55e0a2c731d14cd3c3de6b6c3e64c",
  "changes": {
    "inserts": 3,
    "updates": 0,
    "deletes": 0
  },
  "promoted": false
}

The snapshot overwritten (a refund endpoint added)

POST /provide HTTP/1.1
Authorization: Bearer san_…
Content-Type: application/json

{
  "producername": "payments",
  "stability": "snapshot",
  "openapi_yaml": "openapi: 3.0.3\ninfo:\n  title: Payments\n  version: 2.1.0\npaths:\n  /payments:\n    post:\n      summary: Create a payment\n      responses:\n        \"201\":\n          description: Created\n  /payments/{paymentId}:\n    get:\n      summary: One payment\n      parameters:\n        - name: paymentId\n          in: path\n          required: true\n          schema: { type: string }\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/capture:\n    post:\n      summary: Capture an authorised payment\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/refund:\n    post:\n      summary: Refund a captured payment\n      responses:\n        \"200\":\n          description: OK"
}

HTTP/1.1 202 Accepted
content-type: application/json
content-length: 196

{
  "version": "2.1.0",
  "stability": "snapshot",
  "content_hash": "sha256:bf9e1de18117a28ac401082d4971041fe250238fe9854d727ba9063318bea904",
  "changes": {
    "inserts": 1,
    "updates": 0,
    "deletes": 0
  },
  "promoted": false
}

Promoted to GA

POST /provide HTTP/1.1
Authorization: Bearer san_…
Content-Type: application/json

{
  "producername": "payments",
  "stability": "ga",
  "openapi_yaml": "openapi: 3.0.3\ninfo:\n  title: Payments\n  version: 2.1.0\npaths:\n  /payments:\n    post:\n      summary: Create a payment\n      responses:\n        \"201\":\n          description: Created\n  /payments/{paymentId}:\n    get:\n      summary: One payment\n      parameters:\n        - name: paymentId\n          in: path\n          required: true\n          schema: { type: string }\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/capture:\n    post:\n      summary: Capture an authorised payment\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/refund:\n    post:\n      summary: Refund a captured payment\n      responses:\n        \"200\":\n          description: OK",
  "trunk": true
}

HTTP/1.1 202 Accepted
content-type: application/json
content-length: 189

{
  "version": "2.1.0",
  "stability": "ga",
  "content_hash": "sha256:bf9e1de18117a28ac401082d4971041fe250238fe9854d727ba9063318bea904",
  "changes": {
    "inserts": 0,
    "updates": 0,
    "deletes": 0
  },
  "promoted": true
}

What to notice

  • A snapshot takes new content under the same number without a 409: inserts: 1 on the overwrite.
  • GA of the same bytes is a promotion (promoted: true, zero changes); GA of different bytes would be a 409 like step 4.
  • Consumers that pinned the snapshot saw it change under them; the ETag told their builds.

Go deeper

API lifecycle: iterating on snapshots · Concepts: snapshot and GA · CI integration: ETags

8. The diff between two versions, and one endpoint's history#

Someone in orders asks what 2.0.0 changed. The diff is one call with a session; the history of a single endpoint is another, and the spec viewer shows both.

The diff

GET /admin/producers/payments/diff?api_type=openapi&from=1.0.0&to=2.0.0 HTTP/1.1
Authorization: Bearer <session token>

HTTP/1.1 200 OK
content-type: text/plain; charset=utf-8
content-length: 686

--- payments 1.0.0
+++ payments 2.0.0
@@ -1,7 +1,7 @@
 openapi: 3.0.3
 info:
   title: Payments
-  version: 1.0.0
+  version: 2.0.0
 paths:
   /payments:
     post:
@@ -9,14 +9,20 @@
       responses:
         "201":
           description: Created
-  /payments/{id}:
+  /payments/{paymentId}:
     get:
       summary: One payment
       parameters:
-        - name: id
+        - name: paymentId
           in: path
           required: true
           schema: { type: string }
       responses:
         "200":
           description: OK
+  /payments/{id}/capture:
+    post:
+      summary: Capture an authorised payment
+      responses:
+        "200":
+          description: OK

The trail of POST /payments

GET /endpoint-versions?service=payments&api_type=openapi&path=/payments&method=POST HTTP/1.1
Authorization: Bearer san_…

HTTP/1.1 200 OK
content-type: application/json
content-length: 958

[
  {
    "version": "1.0.0",
    "stability": "ga",
    "provided_by": "root",
    "updated_at": "2026-10-18T16:07:54Z",
    "yaml_content": "openapi: 3.0.3\ninfo:\n  title: Payments\n  version: 1.0.0\npaths:\n  /payments:\n    post:\n      summary: Create a payment\n      responses:\n        '201':\n          description: Created\n",
    "changed": true
  },
  {
    "version": "2.0.0",
    "stability": "ga",
    "provided_by": "root",
    "updated_at": "2026-10-18T16:08:02Z",
    "yaml_content": "openapi: 3.0.3\ninfo:\n  title: Payments\n  version: 2.0.0\npaths:\n  /payments:\n    post:\n      summary: Create a payment\n      responses:\n        '201':\n          description: Created\n",
    "changed": true
  },
  {
    "version": "2.1.0",
    "stability": "ga",
    "provided_by": "root",
    "updated_at": "2026-10-18T16:08:02Z",
    "yaml_content": "openapi: 3.0.3\ninfo:\n  title: Payments\n  version: 2.1.0\npaths:\n  /payments:\n    post:\n      summary: Create a payment\n      responses:\n        '201':\n          description: Created\n",
    "changed": true
  }
]
The spec viewer for POST /payments: version history on the left with 1.0.0, 2.0.0 and 2.1.0, the YAML on the right, blame and compare controls
The spec viewer: one endpoint across the version line.

What to notice

  • The diff is a unified patch of the stored documents; path and method narrow it to one snippet.
  • The trail answers the snippet as stored in each version and marks the ones that changed it.
  • The diff route is administrative and takes a session; the trail route takes an API token, so a script can ask.

Go deeper

The web UI: the spec viewer · GET /admin/producers/{name}/diff · GET /admin/endpoint-versions (the session twin of the call above)

9. A release branch is cut, and a report scoped to it#

Release day. A releaser cuts a sanshain-branch: a named copy of the main graph as it is now. The report scoped to it describes what production will be; a hotfix pipeline on that release requires with the branch's tag, here with the Conan client.

The cut

POST /admin/branches HTTP/1.1
Authorization: Bearer <session token>
Content-Type: application/json

{
  "name": "release-2026.10"
}

HTTP/1.1 201 Created
content-type: application/json
content-length: 137

{
  "id": 1,
  "name": "release-2026.10",
  "created_at": "2026-10-18T16:08:03Z",
  "created_by": "root",
  "source": "trunk",
  "as_of": "2026-10-18T16:08:03Z"
}

The report for the release

GET /report?scope=release-2026.10 HTTP/1.1
Authorization: Bearer san_…

HTTP/1.1 200 OK
content-type: application/json
content-length: 356

{
  "unused_endpoints": [],
  "missing_endpoints": [],
  "dependency_graph": [
    {
      "api_type": "openapi",
      "client": "orders",
      "service": "payments",
      "version": "1.0.0",
      "stability": "ga",
      "path": "/payments",
      "method": "POST",
      "deprecated": false
    }
  ],
  "service_tags": {},
  "trunk_graph": [],
  "harvested_subscriptions": [],
  "peripheral_dependencies": [],
  "systems": {},
  "scope_label": "release-2026.10"
}

A hotfix build of orders on the branch

# recorded with curl; the sanshain-conan invocation is:
$ sanshain-conan --tag release-2026.10 require
The reports page with the graph scope selector
The reports page; the scope selector offers dev, main and the branch.

What to notice

  • The branch records source: trunk and the instant it was born from; the pin set is orders → payments 1.0.0, the trunk pin.
  • scope_label stamps the report; ?scope=main or ?scope=release-2026.10@<instant> work the same way.
  • The tagged require updates the branch's graph and nothing else; the version rules are untouched.

Go deeper

Release graphs: cutting a branch · CI integration: streams · Clients: Conan · POST /admin/branches · Using the API: reports and scopes

10. The catalog push: periphery and context#

catalogInfo: catalog-info.yaml in step 1's file makes every build push the Backstage descriptor too. The Maven plugin does it here; Sanshain reads the owner, the description, the source location, the links and the dependsOn resources, and the node gets a context.

The push, in the pipeline

# recorded with curl; the the Maven plugin invocation is:
$ mvn io.github.paxel.sanshain:sanshain-maven-plugin:2.2.0:catalog -Dsanshain.trunk=true

What the Producer page reads

GET /admin/producers/payments HTTP/1.1
Authorization: Bearer <session token>

HTTP/1.1 200 OK
content-type: application/json
content-length: 402

{
  "name": "payments",
  "context": {
    "owner": {
      "kind": "group",
      "name": "team-payments"
    },
    "description": "Takes payments for the shop and hands them to the bank.",
    "source_location": "https://github.com/acme/payments/tree/main/",
    "links": [
      {
        "url": "https://wiki.acme.corp/payments",
        "title": "Runbook",
        "type": "docs",
        "icon": null
      },
      {
        "url": "https://grafana.acme.corp/d/payments",
        "title": "Dashboard",
        "type": null,
        "icon": null
      }
    ]
  }
}
The context card on the Producer page: owner team-payments (group), the description, and chips for github.com, Runbook and Dashboard
The context card on the Producer page.

What to notice

  • Only resource: refs become peripheral nodes; edges between systems stay Sanshain's own.
  • A repeat push of an unchanged file changes nothing and emits no event.
  • A file Sanshain cannot read as the project's Component is still 200, with recorded: false and a Sanshain-Warning.

Go deeper

CI integration: the catalog push · sanshain.yaml: the catalog phase · Clients: Maven · PUT /catalog/{systemname} · GET /admin/producers/{name}

11. The free validator, before publishing#

For the next version the team checks the draft locally first. POST /validate needs no token and stores nothing; it runs the same parsing, version extraction and splitting a provide would.

A valid draft

POST /validate HTTP/1.1
Content-Type: application/json

{
  "api_type": "openapi",
  "content": "openapi: 3.0.3\ninfo:\n  title: Payments\n  version: 2.2.0\npaths:\n  /payments:\n    post:\n      summary: Create a payment\n      responses:\n        \"201\":\n          description: Created\n  /payments/{paymentId}:\n    get:\n      summary: One payment\n      parameters:\n        - name: paymentId\n          in: path\n          required: true\n          schema: { type: string }\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/capture:\n    post:\n      summary: Capture an authorised payment\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/refund:\n    post:\n      summary: Refund a captured payment\n      responses:\n        \"200\":\n          description: OK"
}

HTTP/1.1 200 OK
content-type: application/json
content-length: 190

{
  "valid": true,
  "api_type": "openapi",
  "version": "2.2.0",
  "endpoint_count": 4,
  "endpoints": [
    "POST /payments",
    "GET /payments/{paymentId}",
    "POST /payments/{id}/capture",
    "POST /payments/{id}/refund"
  ]
}

The same draft without a version

POST /validate HTTP/1.1
Content-Type: application/json

{
  "api_type": "openapi",
  "content": "openapi: 3.0.3\ninfo:\n  title: Payments\npaths:\n  /payments:\n    post:\n      summary: Create a payment\n      responses:\n        \"201\":\n          description: Created\n  /payments/{paymentId}:\n    get:\n      summary: One payment\n      parameters:\n        - name: paymentId\n          in: path\n          required: true\n          schema: { type: string }\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/capture:\n    post:\n      summary: Capture an authorised payment\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/refund:\n    post:\n      summary: Refund a captured payment\n      responses:\n        \"200\":\n          description: OK"
}

HTTP/1.1 200 OK
content-type: application/json
content-length: 146

{
  "valid": false,
  "api_type": "openapi",
  "error": "spec has no info.version — the version is required and lives in the spec file (MAJOR.MINOR.PATCH)"
}

What to notice

  • Always 200 with a verdict, so a script parses one shape; valid: false carries the reason.
  • The version-line rules (steps 4 and 5) are not part of it: those need the Producer's history and run on a real provide or a dry run.

Go deeper

The web UI: the validator · POST /validate

12. An AI assistant asks the catalog over MCP#

A developer configures Claude Code with a read-only token. The token is minted with the account page's call; the assistant's question travels as a JSON-RPC tools/call; and the same token is refused the moment it tries to publish.

A read-only token

POST /auth/tokens HTTP/1.1
Authorization: Bearer <session token>
Content-Type: application/json

{
  "name": "claude-code",
  "expires_in_days": 90,
  "scope": "read_only"
}

HTTP/1.1 200 OK
content-type: application/json
content-length: 165

{
  "id": "376e96e4-3808-4c5f-a6c4-0e4c9deacd14",
  "name": "claude-code",
  "scope": "read_only",
  "token": "san_…"
}

"Who owns payments, and where is its code?"

POST /mcp HTTP/1.1
Authorization: Bearer san_… (read_only)
Content-Type: application/json
Accept: application/json, text/event-stream

{
  "jsonrpc": "2.0",
  "id": 1,
  "method": "tools/call",
  "params": {
    "name": "describe_service",
    "arguments": {
      "name": "payments"
    }
  }
}

HTTP/1.1 200 OK
content-type: application/json
content-length: 2025

{
  "jsonrpc": "2.0",
  "id": 1,
  "result": {
    "content": [
      {
        "type": "text",
        "text": "{\"at\":null,\"consumers\":[{\"api_type\":\"openapi\",\"endpoints\":[{\"method\":\"POST\",\"path\":\"/payments\"}],\"name\":\"orders\",\"version\":\"1.0.0\"}],\"context\":{\"description\":\"Takes payments for the shop and hands them to the bank.\",\"links\":[{\"icon\":null,\"title\":\"Runbook\",\"type\":\"docs\",\"url\":\"https://wiki.acme.corp/payments\"},{\"icon\":null,\"title\":\"Dashboard\",\"type\":null,\"url\":\"https://grafana.acme.corp/d/payments\"}],\"owner\":{\"kind\":\"group\",\"name\":\"team-payments\"},\"source_location\":\"https://github.com/acme/payments/tree/main/\"},\"domain\":null,\"endpoints\":[{\"api_type\":\"openapi\",\"deprecated\":false,\"method\":\"POST\",\"path\":\"/payments\",\"version\":\"2.1.0\"},{\"api_type\":\"openapi\",\"deprecated\":false,\"method\":\"GET\",\"path\":\"/payments/{paymentId}\",\"version\":\"2.1.0\"},{\"api_type\":\"openapi\",\"deprecated\":false,\"method\":\"POST\",\"path\":\"/payments/{id}/capture\",\"version\":\"2.1.0\"},{\"api_type\":\"openapi\",\"deprecated\":false,\"method\":\"POST\",\"path\":\"/payments/{id}/refund\",\"version\":\"2.1.0\"}],\"icon\":null,\"is_consumer\":false,\"is_producer\":true,\"name\":\"payments\",\"periphery\":[{\"resource\":\"resource:default/bank-gateway\",\"shared_with\":[]},{\"resource\":\"resource:default/payments-db\",\"shared_with\":[]}],\"requires\":[],\"scope\":\"dev\",\"versions\":[{\"api_type\":\"openapi\",\"endpoint_count\":4,\"last_provided_at\":\"2026-10-18T16:08:02Z\",\"provided_by\":\"root\",\"stability\":\"ga\",\"superseded\":false,\"version\":\"2.1.0\"},{\"api_type\":\"openapi\",\"endpoint_count\":3,\"last_provided_at\":\"2026-10-18T16:08:02Z\",\"provided_by\":\"root\",\"stability\":\"ga\",\"superseded\":true,\"version\":\"2.0.0\"},{\"api_type\":\"openapi\",\"endpoint_count\":2,\"last_provided_at\":\"2026-10-18T16:07:54Z\",\"provided_by\":\"root\",\"stability\":\"ga\",\"superseded\":true,\"version\":\"1.0.0\"}]}"
      }
    ],
    "isError": false
  }
}

The same token on a provide

POST /provide HTTP/1.1
Authorization: Bearer san_… (read_only)
Content-Type: application/json

{
  "producername": "payments",
  "stability": "ga",
  "openapi_yaml": "openapi: 3.0.3\ninfo:\n  title: Payments\n  version: 2.1.0\npaths:\n  /payments:\n    post:\n      summary: Create a payment\n      responses:\n        \"201\":\n          description: Created\n  /payments/{paymentId}:\n    get:\n      summary: One payment\n      parameters:\n        - name: paymentId\n          in: path\n          required: true\n          schema: { type: string }\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/capture:\n    post:\n      summary: Capture an authorised payment\n      responses:\n        \"200\":\n          description: OK\n  /payments/{id}/refund:\n    post:\n      summary: Refund a captured payment\n      responses:\n        \"200\":\n          description: OK"
}

HTTP/1.1 403 Forbidden
content-type: application/json
content-length: 153

{
  "error": "this API token has scope 'read_only' and may not change the catalog; create a token with scope 'full' for provide, require and catalog pushes"
}

What to notice

  • The tool answers the version lines, the consumers and their pins, and the context from step 10, in one compact document.
  • A read-only token cannot provide, require or push, whoever owns it: the scope is fixed at creation.
  • In Claude Code the configuration is one line: claude mcp add --transport http sanshain https://sanshain.corp/mcp --header "Authorization: Bearer san_…".

Go deeper

MCP: the token · MCP: client configuration · MCP: the tools · POST /auth/tokens · Using the API: scopes

Behind the story#

The single tasks of this story, and a few the story does not tell, are the Developer recipes. The operator who installed this instance has a story of their own. To re-record both against a newer release, run just record-walkthroughs in the repository: it builds the service, starts a fresh instance, replays every step with the real clients (cargo-sanshain, the JavaScript client, the Go client, the Conan client, the Maven plugin) and curl, compares every answer with what the step expects, and overwrites site/evidence/.

Recorded against v2.6.1.