Skip to content

Polychro — Tutorial

You run a maritime shipping company called Shipyard. Your fleet — ships, crews, voyages — is exposed through a REST contract: the Modern Maritime Registry API. Before that contract reaches an SDK generator, an AI agent, or a partner integration, it has to be correct. Not "looks fine in the editor" correct — provably correct, the same way on every machine and in CI.

That is what Polychro does. This track takes one OpenAPI contract — scoped to just /ships, /ships/{imo_number}, and /crew — and lints it through the whole pipeline, one layer at a time. You start with a single command and zero configuration. By the end you have a home-made ruleset, an external schema, a custom polyglot rule, and agent-native output ready to drop into a generation loop.

No deploy. No backend. Just polychro lint and a contract that gets stricter at every step.

Prerequisites. A polychro binary on your PATH (see Installation). All example files for this track live in examples/tutorial/track-1-yaml/ in the public Polychro repository. Run every command from inside that directory so the relative paths (openapi-ruleset.yml, .polychro.yml, functions/…) resolve.

🚧 Beta release & current limitations. Polychro is in 1.0.0-beta2. Every command and every diagnostic on this page was produced by running the real 1.0.0-beta2 CLI from the example directory — what you see here is exactly what you will get. Two things are worth knowing up front:

  • A spurious non-string-key warning on every step. The contract quotes its HTTP status codes ("200":) — the idiomatic, valid OpenAPI form — but the well-formedness validator currently flags any key that looks numeric, regardless of quoting. It is a known false positive, not a defect in the contract, and its only effect here is to raise Steps 1 and 7 from a clean exit 0 to exit 1. (polychro#78)
  • Step 4's external schema is not wired yet. The config.json-schema block in .polychro.yml is not consumed: the CLI reports a configuration error instead of the type violation the narrative expects. (polychro#79)

Both will close in a later release; until then the page shows the real output and points out exactly where it differs from the intended behaviour.


Step 1 — Just run it

File: step-1-openapi.yml

No config, no schema, no ruleset. Point polychro at the contract and read what comes back. This is Polychro's "zero to signal" moment: the well-formedness validator runs implicitly, on every file, before anything else.

# step-1-openapi.yml — the Modern Maritime Registry, three read-only paths
openapi: 3.0.3
info:
  title: Modern Maritime Registry API
  version: 1.0.0
paths:
  /ships:
    summary: Collection of registered ships.
    get:
      operationId: list-ships
      summary: List all registered ships.
      responses:
        "200":
          description: A list of ships.
  /ships/{imo_number}: { ... }   # get-ship
  /crew:                { ... }   # list-crew

Note: the two last lines are abridged for readability. The downloaded file is the source of truth — copy that, not this block.

A clean contract. The only thing Polychro flags is the non-string-key false positive described in the beta note above.

Run it

# Download the file
curl -L -o step-1-openapi.yml \
  https://raw.githubusercontent.com/naftiko/polychro/main/examples/tutorial/track-1-yaml/step-1-openapi.yml

polychro lint step-1-openapi.yml

Expected output:

WARN [non-string-key]: Non-string YAML key: 200

1 issue(s) found.

Exit code 1 — warnings only, no errors. (The intended result is a clean exit 0; the single warning is the known non-string-key false positive.)

🧭 What you learned: polychro lint <file> with zero configuration; well-formedness runs implicitly first; what a diagnostic is (severity, code, message); the exit-code contract (0 clean, 1 warnings, 2 errors).


Step 2 — Well-formedness as the first gate

File: step-2-openapi.yml

Semantic checks are pointless on a structurally broken file — so Polychro gates everything behind well-formedness. To prove it, the /ships path item here declares summary twice:

  /ships:
    summary: Collection of registered ships.
    summary: Collection of all registered ships.   # ← duplicate key
    get:
      operationId: list-ships
      # ...

A duplicated mapping key is a structural defect. Polychro raises it as an ERROR — and because it is a well-formedness error, the pipeline does not proceed to deeper checks.

Run it

curl -L -o step-2-openapi.yml \
  https://raw.githubusercontent.com/naftiko/polychro/main/examples/tutorial/track-1-yaml/step-2-openapi.yml

polychro lint step-2-openapi.yml

Expected output:

ERROR [duplicate-key]: Duplicate key: summary
WARN [non-string-key]: Non-string YAML key: 200

2 issue(s) found.

Exit code 2 — at least one error is present.

🧭 What you learned: well-formedness is the first gate; duplicate-key fires as an ERROR on a duplicated mapping key; an ERROR drives the exit code to 2.


Step 3 — A home-made OpenAPI ruleset

Files: step-3-openapi.yml · openapi-ruleset.yml

Polychro's built-in rulesets (polychro:governance, polychro:ai-safety, polychro:security) are capability-centric — they match an Ikanos capability document, not OpenAPI's $.paths and operationId. So to govern an OpenAPI contract you bring your own ruleset. One is provided for you; you don't author anything yet, you just pass it.

The first rule it carries: every path item must declare a summary.

# openapi-ruleset.yml (excerpt — Step 3's rule)
aliases:
  Paths: "$.paths[*]"

rules:
  path-summary-present:
    message: "Each path item must declare a `summary`."
    severity: warn
    given: "#Paths"
    then:
      field: "summary"
      function: truthy

In step-3-openapi.yml the /ships/{imo_number} path item has its summary removed, so the rule bites exactly once, at that JSONPath location.

Run it

curl -L -o step-3-openapi.yml \
  https://raw.githubusercontent.com/naftiko/polychro/main/examples/tutorial/track-1-yaml/step-3-openapi.yml
curl -L -o openapi-ruleset.yml \
  https://raw.githubusercontent.com/naftiko/polychro/main/examples/tutorial/track-1-yaml/openapi-ruleset.yml

polychro lint --ruleset openapi-ruleset.yml step-3-openapi.yml

Expected output:

WARN at $.paths./ships/{imo_number}.summary [path-summary-present]: Each path item must declare a `summary`.
WARN [non-string-key]: Non-string YAML key: 200

2 issue(s) found.

Exit code 1path-summary-present is a warning, and the rule fires once, with the diagnostic anchored to $.paths./ships/{imo_number}.summary.

🧭 What you learned: the built-in rulesets are capability-centric and do not bite on OpenAPI; --ruleset <file> runs a custom ruleset; given + then with the truthy function; rules fire once, anchored to a JSONPath.


Step 4 — Pin the structure with an external schema

Files: step-4-openapi.yml · .polychro.yml · openapi-schema.json

A ruleset checks conventions; a schema pins shape. This step introduces a project config file (.polychro.yml) that points the schema-model stage at a small JSON Schema for the contract. The schema requires info.version to be a string — and step-4-openapi.yml deliberately writes it unquoted:

# step-4-openapi.yml (excerpt — the planted defect)
info:
  title: Modern Maritime Registry API
  version: 1.0        # ← parses as a float, not a string
# .polychro.yml
validators: []
config:
  json-schema:
    schemaPath: openapi-schema.json

The intended result is a JSON-Schema type ERROR at $.info.version — the float 1.0 violating the schema's "type": "string".

🚧 Current limitation — not wired yet in 1.0.0-beta2. The config.json-schema block in .polychro.yml is not consumed yet: the schema-model stage does not run, and the CLI reports a configuration error instead of the type violation. This is a known, tracked limitation — not a defect in the example files. The narrative above describes the intended behaviour; the actual 1.0.0-beta2 output is below.

Run it

curl -L -o step-4-openapi.yml \
  https://raw.githubusercontent.com/naftiko/polychro/main/examples/tutorial/track-1-yaml/step-4-openapi.yml
curl -L -o .polychro.yml \
  https://raw.githubusercontent.com/naftiko/polychro/main/examples/tutorial/track-1-yaml/.polychro.yml
curl -L -o openapi-schema.json \
  https://raw.githubusercontent.com/naftiko/polychro/main/examples/tutorial/track-1-yaml/openapi-schema.json

polychro lint --config .polychro.yml step-4-openapi.yml

Expected output (1.0.0-beta2, the known limitation):

Error: JsonStructureValidatorFactory requires 'schemaNode', 'schemaPath', or an explicit 'mode' in config

Exit code 2. Intended output (once the config wiring is fixed): a JSON-Schema type ERROR at $.info.version.

🧭 What you learned: the role of the schema-model stage and a .polychro.yml project config; how a JSON Schema pins the shape of a contract (here, info.version must be a string); and an honest look at a feature that is declared but not yet wired end-to-end in 1.0.0-beta2.


Step 5 — Extend the ruleset with house rules

Files: step-5-openapi.yml · openapi-ruleset.yml

Now you write the rules people actually write in Spectral — API-style governance. The same ruleset gains two: every operation must declare an operationId, and every operation must declare a responses object.

# openapi-ruleset.yml (excerpt — Step 5's rules)
  operation-id-present:
    message: "Each operation must declare an `operationId`."
    severity: error
    given:
      - "$.paths[*].get"
      - "$.paths[*].post"
      # ...put / delete / patch
    then:
      field: "operationId"
      function: truthy

  operation-responses-present:
    message: "Each operation must declare a `responses` object."
    severity: error
    given:
      - "$.paths[*].get"
      # ...
    then:
      field: "responses"
      function: defined

In step-5-openapi.yml the GET /crew operation has its operationId removed, so operation-id-present fires as an ERROR at that operation.

Run it

curl -L -o step-5-openapi.yml \
  https://raw.githubusercontent.com/naftiko/polychro/main/examples/tutorial/track-1-yaml/step-5-openapi.yml
# (openapi-ruleset.yml already downloaded in Step 3)

polychro lint --ruleset openapi-ruleset.yml step-5-openapi.yml

Expected output:

ERROR at $.paths./crew.get.operationId [operation-id-present]: Each operation must declare an `operationId`.
WARN [non-string-key]: Non-string YAML key: 200

2 issue(s) found.

Exit code 2 — a missing operationId is an error.

🧭 What you learned: authoring your own given/then rules; multi-path given selectors; the truthy and defined functions; promoting governance from WARN (Step 3) to ERROR; rules anchored to the offending operation.


Step 6 — Leave native: a custom polyglot function

Files: step-6-openapi.yml · openapi-ruleset.yml · functions/operation-id-unique.js

Some rules a schema simply cannot express. "No two operations may share an operationId" is cross-cutting — it has to walk the whole document. For that, you leave the declarative given/then world and write a polyglot JavaScript function that runs on GraalVM.

The ruleset wires it in by name:

# openapi-ruleset.yml (excerpt — Step 6's wiring)
functionsDir: functions
functions:
  - operation-id-unique

rules:
  operation-id-unique:
    message: "Every `operationId` must be unique across the whole contract."
    severity: error
    given: "$"
    then:
      function: operation-id-unique
// functions/operation-id-unique.js (excerpt)
export default function operationIdUnique(targetVal) {
  const seen = new Map();
  const results = [];
  // ...walk every paths.<path>.<method>.operationId
  // push a result {message, path} on each duplicate
  return results;
}

In step-6-openapi.yml the GET /ships operation reuses get-ship (already used by GET /ships/{imo_number}), so the function reports the duplicate. Note the ruleset's own message overrides the message returned by the function.

Run it

curl -L -o step-6-openapi.yml \
  https://raw.githubusercontent.com/naftiko/polychro/main/examples/tutorial/track-1-yaml/step-6-openapi.yml
mkdir -p functions
curl -L -o functions/operation-id-unique.js \
  https://raw.githubusercontent.com/naftiko/polychro/main/examples/tutorial/track-1-yaml/functions/operation-id-unique.js

polychro lint --ruleset openapi-ruleset.yml step-6-openapi.yml

Expected output:

ERROR at $.paths./ships/{imo_number}.get.operationId [operation-id-unique]: Every `operationId` must be unique across the whole contract.
WARN [non-string-key]: Non-string YAML key: 200

2 issue(s) found.

Exit code 2. (When the polyglot function runs, GraalVM/Truffle may print interpreter warnings to stderr — these are not diagnostics and do not affect the result.)

🧭 What you learned: when a rule needs more than given/then; wiring a polyglot function with functionsDir + functions; the function contract (return {message, path} per violation); the ruleset message overriding the function's own.


Step 7 — Close the loop: agent-native output

File: step-7-openapi.yml

The final layer is integration. AI agents don't want pretty text — they want compact, structured JSON they can act on and a token cost they can budget against. The --format agent formatter gives them exactly that.

curl -L -o step-7-openapi.yml \
  https://raw.githubusercontent.com/naftiko/polychro/main/examples/tutorial/track-1-yaml/step-7-openapi.yml

polychro lint --format agent step-7-openapi.yml

Expected output:

{
  "diagnostics": [
    {
      "severity": "warning",
      "rule": "non-string-key",
      "message": "Non-string YAML key: 200"
    }
  ],
  "summary": { "errors": 0, "warnings": 1, "info": 0 },
  "tokens": 55
}

Exit code 1. The contract itself is clean; the single warning is the known non-string-key false positive (the intended result here is exit 0). The tokens field pre-computes the context-window cost so an agent can decide whether to inline every diagnostic or summarize.

This is also the bridge to the rest of the platform. You can run the same engine as an MCP server —

polychro serve --ruleset polychro:ai-safety

— so an agent calls a lint tool, gets these structured diagnostics back, and self-corrects before its output is ever deployed.

🧭 What you learned: --format agent for token-efficient, agent-native JSON; the summary and tokens fields; polychro serve to expose linting as an MCP tool inside an agent loop.


Recap

You started with one command and ended with a fully governed maritime contract. Along the way Polychro:

  • Gated everything behind well-formedness (Steps 1–2)
  • Governed the contract with a home-made OpenAPI ruleset (Steps 3, 5)
  • Pinned its shape with an external schema (Step 4 — intended)
  • Enforced a cross-cutting invariant with a polyglot function (Step 6)
  • Emitted agent-native output ready for a generation loop (Step 7)

All from polychro lint. All deterministic. All reproducible in CI.

Two 1.0.0-beta2 limitations were surfaced honestly: the non-string-key false positive on quoted status codes, and the unconsumed schema config in Step 4. Both are tracked and will close in a later release — this page will be updated to match the day they do.

Where to next

  • The built-in, capability-centric rulesets (polychro:governance, polychro:ai-safety, polychro:security) govern the Ikanos capabilities that consume contracts like this one — see Guide: Rulesets.
  • CLI reference — every command, option, and exit code.
  • Guide: MCP server — wire polychro serve into Claude Desktop, Cursor, and other MCP clients.