Contract workflow¶
Contract
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_resourceVerbpattern. - Every operation has
tags,summary,description. - Path parameters are
kebab-case. - Every response has
application/jsonschema. - 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:
make codegen— regenerates Go + TS types.- Write
service_test.gowith unit cases (success + all error paths). - Implement service function
Restart(ctx, in). - Implement handler.
- Integration test against real Postgres.
- 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().