Skip to main content

Contract v1 — Frozen at G-01

docs/contract-v1.md + docs/openapi-v1.json + docs/contracts/examples/*.json + the exported fixture bundle are one artefact. Changing anything here is a G-01 contract change requiring coordinated edits in both repos — not a file edit.

Version box

KeyValue
schemaVersion1
contractVersion1 (path prefix /api/v1)
seedVersion1
referenceNow2026-08-01T12:00:00Z
Locale / TZ of demo dataen-GB / Europe/London
API ids/api/v1

Primitives

  • Identifiers — non-empty ≤64 chars, case-sensitive; seed uses BKG-, RPT-, INS-, CHG-, NTF-, TCK; task id is client UUID (authoritative); attachment ids opaque att_<32 hex>.
  • Instants^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$, UTC Z only, offsets/naive rejected; domain UTC, presentation localizes.
  • MoneyamountMinor int (minor units) + currency ISO 4217; floats rejected; totals per-currency never combined across currencies.
  • Optionality — absent is null, collections always present non-null.
  • Text length — extended grapheme clusters (UAX #29): CR LF, combining/spacing marks, ZWJ emoji, modifiers, variation selectors, flag pairs, Hangul; server graphemes.py = client package:characters.

Enumerations (frozen)

EnumValues
InspectionTypepreArrival, postArrival, midStay, checkout
RecordStatuspending, completed
TaskStatusnew, inProgress, outstanding, accepted, completed
TaskCategoryelectrical, plumbing, heating, appliance, furniture, cleaning, other
ChargeTypereplace, repair, clean
ChargeStatusoutstanding, accepted, contested, resolved, paid
AcceptanceOriginstudent, deadline, operator
NotificationTypechargeRaised, chargeDeadlineApproaching, chargeAccepted, chargeResolved, chargePaid
EventTypecharge.created, charge.updated, task.created, notification.updated, sync.required
ResourceTypecharge, task, notification
ChangeTypecreated, updated

Unknown enum value ⇒ protocol error (never tolerated).

Entities (storage shape)

  • Booking id, propertyCode, roomName, displayLocation, startDate, endDate
  • InventoryReport id, bookingId, name, summary, status, location, completedOn, reportUrl, downloadMediaType, downloadFileName
  • Inspection id, bookingId, type, status, location, roomName, date, generalNotes[], itemActions[], itemUpdates[]
  • GeneralNote id, recordedAt, text — inspector prose, never translated
  • ItemAction id, itemName, notes, thumbnailUrl, chargeId, amountMinorchargeId+amountMinor both present or both null
  • ItemUpdate id, itemName, conditionNote
  • MaintenanceTask id, bookingId, category, notes, location, date, status
  • Charge id, bookingId, inspectionId, itemName, type, notes, location, amountMinor, currency, status, raisedAt, gracePeriodDays, deadlineAt, photos[], contestReason, contestAttachments[], acceptedAt, contestedAt, resolvedAt, paidAt, acceptanceOrigin, version, updatedAt
  • Photo id, url, thumbnailUrl, mediaType, width, height, altKey, sortOrder
  • ContestAttachment id, displayName, mediaType, sizeBytes, sha256, downloadUrl, thumbnailUrl?
  • AppNotification id, type, titleKey, bodyKey, chargeId, createdAt, readtitleKey/bodyKey/altKey are L10n keys
  • HubSnapshot booking, inventoryReports[], inspections[], tasks[], charges[], notifications[], generatedAt — no pagination in v1

version starts 1, +1 per committed mutation incl. deadline reconciliation.

Deadline rule

deadlineAt = raisedAt + gracePeriodDays UTC; default 30 days; inclusive expiry now >= deadlineAt — boundary instant already elapsed. Vectors pin leap-day + offset-change day and -1s/0s/+1s.

Reconciliation (under store lock before every charge/Hub read, before every mutation, and via scheduler): status=accepted, acceptedAt=deadlineAt, updatedAt=<reconciliation time>, acceptanceOrigin=deadline, version+1, charge.updated event. Idempotent second pass no-ops. If it wins a race against a student command, the 409 carries currentCharge.

Envelope & revisions

Every JSON response is one of:

{"data": ..., "meta": {"schemaVersion":"1","requestId":"...","serverTime":"...Z","stateEpoch":"<uuid>","storeRevision":42}}
{"error": {"code":"charge.invalid_transition","message":"...","details":{...}},
"meta": {"schemaVersion":"1","requestId":"...","serverTime":"...Z","stateEpoch":"<uuid>","storeRevision":42}}

Exceptions: attachment bytes+headers, text/event-stream at GET /api/v1/events, 204 at POST /api/v1/_dev/reset.

  • stateEpoch — changes only on destructive reset/reseed.
  • storeRevision — increments on every committed snapshot, survives restart.
  • streamEpoch + streamCursor — added by GET /api/v1/sync-snapshot in its meta.

Every non-dev mutation (incl. idempotent notification read) sends expectedStateEpoch checked under lock; mismatch ⇒ store.epoch_mismatch 409.

Idempotency

Idempotency-Key: <uuid> required for Accept, Contest, Pay, task create. Not used by reads/notification read/dev.

Scope (method, normalised route template, resource id, key) — same key against different charge is different operation. Digest = SHA-256(RFC8785(canonical JSON of body)); Contest digests {"metadata":{…},"attachments": [[sha256,sizeBytes,mediaType,sanitizedDisplayName], …]} in user order; multipart boundaries/header order ignored.

Ledger caches only committed 2xx inside same atomic commit. So:

  • same key+digest ⇒ replay exact committed response, no second event/version/attachment, even after restart;
  • same key+different digest ⇒ idempotency.payload_mismatch 409;
  • malformed/4xx/5xx never cached;
  • missing key on command ⇒ request.idempotency_key_missing 400.

Why frozen matters

Downstream Dart code gen (kx_api.dart, domain_mapper.dart) and vectors assume this text. If a wire field or enum moves, Flutter's api_models.dart and Drift mapper drift at runtime — contract tests are the tripwire, not the docs site.