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
| Key | Value |
|---|---|
schemaVersion | 1 |
contractVersion | 1 (path prefix /api/v1) |
seedVersion | 1 |
referenceNow | 2026-08-01T12:00:00Z |
| Locale / TZ of demo data | en-GB / Europe/London |
| API ids | /api/v1 |
Primitives
- Identifiers — non-empty ≤64 chars, case-sensitive; seed uses
BKG-,RPT-,INS-,CHG-,NTF-,TCK; taskidis client UUID (authoritative); attachment ids opaqueatt_<32 hex>. - Instants —
^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$, UTCZonly, offsets/naive rejected; domain UTC, presentation localizes. - Money —
amountMinorint (minor units) +currencyISO 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= clientpackage:characters.
Enumerations (frozen)
| Enum | Values |
|---|---|
InspectionType | preArrival, postArrival, midStay, checkout |
RecordStatus | pending, completed |
TaskStatus | new, inProgress, outstanding, accepted, completed |
TaskCategory | electrical, plumbing, heating, appliance, furniture, cleaning, other |
ChargeType | replace, repair, clean |
ChargeStatus | outstanding, accepted, contested, resolved, paid |
AcceptanceOrigin | student, deadline, operator |
NotificationType | chargeRaised, chargeDeadlineApproaching, chargeAccepted, chargeResolved, chargePaid |
EventType | charge.created, charge.updated, task.created, notification.updated, sync.required |
ResourceType | charge, task, notification |
ChangeType | created, 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, amountMinor—chargeId+amountMinorboth 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, read—titleKey/bodyKey/altKeyare 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 byGET /api/v1/sync-snapshotin itsmeta.
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_mismatch409; - malformed/4xx/5xx never cached;
- missing key on command ⇒
request.idempotency_key_missing400.
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.