Skip to content

Contract workflow

Contract

Source: doc/api/openapi.draft.yaml · doc/api/asyncapi.draft.yaml · doc/governance/Contract_Versioning_Policy.md · scripts/codegen.sh

The contract-first loop

flowchart LR
    A[Need: new endpoint<br/>or event payload change] --> B[Edit OpenAPI /<br/>AsyncAPI fragment or draft]
    B --> C[scripts/codegen.sh<br/>regenerate Go + TS]
    C --> D[Write unit + handler tests<br/>before service code]
    D --> E[Implement service<br/>+ handler]
    E --> F[Integration test]
    F --> G[CI gates:<br/>contracts_validate,<br/>contracts_breaking_change,<br/>audit_presence,<br/>canonical_error,<br/>observability_trace]
    G --> H[PR review + merge]

Author location

Per doc/api/openapi/manifest.yaml, each domain has either:

  • Migrated to fragments: edit the domain fragment under doc/api/openapi/<domain>/.
  • Not yet migrated: edit the canonical doc/api/openapi.draft.yaml.

The manifest is the source of truth for which is which.

Same model for AsyncAPI: doc/api/asyncapi/manifest.yaml and doc/api/asyncapi/<domain>/.

Code generation

scripts/codegen.sh runs:

Output Tool Used by
packages/shared/gen/openapigen/ oapi-codegen cmd/api handlers (boundary only)
packages/web/src/types/openapi.d.ts openapi-typescript packages/web/
sdk/python/ OpenAPI generator Python SDK

Make target: make codegen.

Codegen is the only place generated types appear. Internal service logic uses hand-written domain types with explicit mapping at the HTTP boundary.

Error envelope contract

Every REST error must use this shape:

ErrorResponse:
  type: object
  required: [code, message, correlation_id]
  properties:
    code:           # must be from the catalog
      type: string
    message:
      type: string
    correlation_id:
      type: string
    details:        # required for validation_error
      type: object

Source: packages/shared/errors. Catalog: Error codes reference.

Event envelope contract

Envelope:
  type: object
  required: [event_id, event_type, occurred_at, version, correlation_id, payload]
  properties:
    event_id:        { type: string, format: uuid }
    event_type:      { type: string, pattern: '^[a-z]+(\.[a-z_]+)+$' }
    occurred_at:     { type: string, format: date-time }
    version:         { type: string }
    correlation_id:  { type: string }
    payload:         { type: object }

Contract versioning policy

From Contract_Versioning_Policy.md:

  • Additive changes (new endpoint, new optional field) → minor version bump.
  • Breaking changes (rename, remove, required-add) → major version bump + deprecation window.
  • Every breaking change requires a written rollout note + migration guide.

CI gate contracts_breaking_change.sh diffs the current spec against main and blocks unannotated breakage.

Spectral lint policy

doc/governance/openapi.spectral.yaml defines lint rules:

  • Operation IDs follow domain_resourceVerb pattern.
  • Every operation has tags, summary, description.
  • Path parameters are kebab-case.
  • Every response has application/json schema.
  • Error responses use ErrorResponse.

CI gate: scripts/ci/contracts_validate.sh.

Sample: adding an endpoint

# doc/api/openapi/allocations/paths.yaml
/api/v1/allocations/{allocation_id}/restart:
  post:
    operationId: allocation_restart
    tags: [allocations]
    summary: Restart an active allocation
    description: |
      Restart an active allocation by orchestrating a stop+start cycle on the same node.
      Idempotent — repeated calls with the same X-Idempotency-Key are safe.
    parameters:
      - $ref: '#/components/parameters/AllocationId'
      - $ref: '#/components/parameters/IdempotencyKey'
    requestBody:
      content:
        application/json:
          schema:
            $ref: '#/components/schemas/AllocationRestartRequest'
    responses:
      '202':
        description: Restart accepted
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/AllocationRestartResponse'
      '404':
        $ref: '#/components/responses/AllocationNotFound'
      '409':
        $ref: '#/components/responses/AllocationNotActive'

After editing the spec:

  1. make codegen — regenerates Go + TS types.
  2. Write service_test.go with unit cases (success + all error paths).
  3. Implement service function Restart(ctx, in).
  4. Implement handler.
  5. Integration test against real Postgres.
  6. Open PR — CI will check contract validity, breaking changes, error envelope, audit row presence.

Sample: adding an event

# doc/api/asyncapi/provisioning/messages.yaml
ProvisioningRestartRequested:
  name: provisioning.restart_requested
  title: Allocation restart requested
  payload:
    type: object
    required: [allocation_id, requested_by_user_id]
    properties:
      allocation_id:        { type: string, format: uuid }
      requested_by_user_id: { type: string, format: uuid }
      reason:               { type: string }

Don't forget to register the subject in packages/shared/events.InitStreams().

Where to look next