Skip to content

0046. Housekeeping tasks, maintenance tickets and fault photos

  • Status: Accepted
  • Date: 2026-10-31, amended 2026-11-01 (planning, automatic assignment, checklists, task events, object storage)
  • Deciders: maintainers; the photo storage was the coordinator’s decision

Until now housekeeping was one status per room (dirty, clean, inspected), set by hand or made dirty by a check-out, and a maintenance ticket was an open/done note that could link a room block (ADR 0033). The analysis asks for more (domain, “Room readiness”; mobile; interface, “Mobile housekeeping”; UI research §3.5 and §4.3): cleaning tasks generated and rescheduled idempotently on departures, room moves and cancellations; manual assignment in v1; check-in readiness “clean, inspected if the property requires it, and no active block”; closing a maintenance ticket never marks the room clean; fault reports with photos that open a ticket and can take the room out of order; Undo for routine changes instead of confirmations.

Three constraints shape the design. A reservation can change from many places (front desk, planning moves and resizes, policies, OTA imports), and the planning track changes them in parallel with this one. The night audit belongs to another track and must not be touched. Photos must cost nothing to store on the free tiers the project targets (Vercel with Neon or Supabase).

A pure planner (plannedTasks in core/reservations/housekeeping-tasks.ts) derives the tasks a reservation owes from its rooms and their stay segments:

Task Due Room Key
Departure clean the departure date, or the check-out day after an early departure the room of the last night departure:<room>
Move clean the first night in the new room, also a future one the room the guest leaves room_move:<room>:<date>
Stayover service each day of the stay after arrival, at the property’s cadence (off, daily, every N nights, N 2–30), except a move day the room of that night stayover:<room>:<date>

Cancelled, OTA-cancelled and no-show rooms owe nothing; a room without an assignment owes nothing until one is given. The key is stored as origin_key, unique per workspace and property: a database constraint makes a second copy of the same task impossible.

Every reservation write calls rescheduleStayTasks in its own transaction, right after saving the reservation: the shared persist step of the front desk, planning and policy use cases, and the OTA import. It synchronizes the reservation’s generated tasks from the business date on:

  • a pending task whose stay fact moved (new date, new room) is rescheduled in place;
  • a pending task whose fact is gone (dates shortened, move undone, room or booking cancelled, no-show) is cancelled;
  • work that has started, finished or was skipped is never touched;
  • a task the stays owe is created only on a generated day (below), and a cancelled one returns there.

The planning track’s resize and undo go through the same move and date-change use cases, so they reschedule tasks without knowing about them. Every task a synchronization creates, cancels or brings back records its own event (see “Webhook events of tasks”).

Days are generated on demand, idempotently

Section titled “Days are generated on demand, idempotently”

POST /properties/{id}/housekeeping_days with a date generates that day: it records the day in housekeeping_days and synchronizes every reservation that can owe a task on it (a live room arriving before the next day and leaving on or after it, plus those with a pending generated task due that day), read in batches of 100. Running it again only adds what the stays now owe and cancels what they no longer owe. Days from the business date to 30 days after it can be generated; the board offers “Generate tasks” for a day that has none. Saving the settings re-synchronizes the generated days from the business date on, so a new stayover cadence takes effect at once. Nothing is generated from inside the night audit, and no job runs on a schedule.

Why on demand rather than eagerly: stayover tasks for every future night of every booking would have to be rewritten on each settings change, and a stay booked months ahead would fill boards nobody plans yet. A generated day is the head housekeeper’s plan; reservation writes keep the plans that exist up to date.

A room is ready when it is clean, or inspected when the property’s inspection_required setting is on (unitReady). Check-in, room moves of an in-house guest and Today’s “not ready” list use the same rule; the readiness override with its permission and reason is unchanged.

Tasks move freely between pending, in progress, done and skipped (skipped needs a reason, for example “Do not disturb”); cancelled is set only by synchronization. Because every move is allowed, Undo is the inverse change with the task’s ETag, never a special endpoint. Finishing a clean makes the room clean (through setRoomStatus, the one writer of a room’s status that the status buttons and check-out also use) and, when inspection is required, creates an inspection task for the same room, due on the clean’s day or the business date if that is later; finishing an inspection makes the room inspected. The task stores the room status before and after, so that reopening it (Undo) restores the previous status only if the room still has the status the task gave it, and withdraws the inspection it created while that is still pending. Bulk status changes check every task before writing any.

Reading tasks, days and settings needs inventory.read, which already owns room readiness (ADR 0036); the earlier housekeeping.read code stays removed. Working on tasks keeps housekeeping.update. Two grants are new: housekeeping.assign (built in for administrators, front desk and housekeeping) and housekeeping.configure (administrators) for the settings.

Members with the new housekeeping.assign grant generate days, create manual tasks (clean, deep clean, inspection, turndown, other) with a note, due date and priority, and assign or reassign tasks one by one or up to 100 at once, all or none. A task can be given only to a member who holds housekeeping.update at that property right now (role or custom role, and property access); a ticket only to one who holds maintenance.manage there. The board lists them through GET /properties/{id}/housekeeping_assignees and maintenance_assignees.

Board order is arrivals first: a task whose room expects a guest on its due date comes before the others, then priority (high, normal, low), then the room order.

The head housekeeper plans a day with three pieces of data:

  • Sections. A room’s existing floor is its section for housekeeping: a floor, a wing or a building, as the property names it. No second field was added: a property that thinks in wings types the wing there, and the screens call it “Floor or section”.
  • The roster of a day (/housekeeping_rosters/{date}): the members who work, each with a start time, a capacity in credits and optionally the section they cover, in the order a person listed them. Only members a task can be given to (housekeeping.update at the property) can be on it. It is a document per day with a version, like the settings; an unplanned day is empty at version
    1. Saving needs housekeeping.assign, the same grant as assigning.
  • Credits (/housekeeping_credits): how much work each task type takes, per room type or for any room type. Without a rule a type takes its default: 3 for a departure or move clean, 5 for a deep clean, 2 for a clean, 1 for the rest. Saving needs housekeeping.configure.

Automatic assignment distributes a day’s work over its roster. A pure planner (core/housekeeping/assignment.ts) gives the same plan for the same tasks, roster and credits, whatever order they were read in:

  • Movable tasks are pending, not pinned, and unassigned or assigned automatically. Everything else is kept: a person’s assignment, a pinned task (assigned or not) and work already begun. Kept work that is pending, in progress or done counts against its assignee’s capacity.
  • Members are seated by start time, then roster order. Movable tasks are taken in board order (arrivals first, priority, section, room). Each goes to the member with enough capacity left that it costs least: a member of its section, then one without a section, then one of another; a section the member already works in; for an arrival room, the earliest start; the lowest share of capacity after it; the roster order. A task nobody has room for stays unassigned, with the reason (no_staff or no_capacity); one that automatic assignment gave earlier is taken back, so the preview’s “stays unassigned” is what applying leaves.
  • Only roster members who can be given tasks at the property now are seated: the roster is checked when it is saved, and again by every preview and apply, so a member who lost the grant or the property since is shown as unavailable and gets nothing.
  • Re-running the plan re-plans its own earlier, unpinned choices, so that a changed roster is taken into account; manual and pinned assignments never move. Assigning a task by hand, even to the same member, makes it a manual assignment; pinning (PATCH …/housekeeping_tasks/{id}, housekeeping.assign) keeps any task as it is.

The board previews the plan first (GET /housekeeping_assignment_plans/{date}): each change from and to whom, the credits per member, and what stays unassigned and why. The preview carries a basis, a digest of what applying would write (the changed tasks with their versions and new assignees, and the unassigned ones). Applying (POST /housekeeping_assignment_runs with the date and the basis) recomputes the plan in the write transaction and refuses one that differs (housekeeping.plan_changed) or changes nothing (housekeeping.plan_empty), so a member never applies something other than what they saw. It is one audited operation: each changed task gets its assignee and its next version and records housekeeping_task.assigned, and the run keeps, per task, its previous assignee, how it had been chosen and the version the run left.

Undo is exact (POST /housekeeping_assignment_runs/{id}/undo): every task of the run gets back its previous assignee and how it had been chosen, or nothing happens. It is refused (housekeeping.undo_conflict) when the run was undone already, when anything changed one of its tasks since (its version moved on), or when a previous assignee can no longer be given tasks there. There is no partial undo: restoring some tasks and not others would leave a plan nobody chose. The board offers Undo for 10 seconds after applying and keeps it on the day’s run history for as long as it is possible.

Why credits rather than minutes: properties price a departure clean against a stayover tidy in their own terms, and a capacity in the same unit needs no time model of rooms, travel or breaks. Start times order the work, so arrival rooms go to whoever starts first.

A template (/housekeeping_checklists, housekeeping.configure) names a task type and a room type, or the task type in any room type, and holds 1 to 50 items. Each item has a stable key, a label, whether it is required, and a kind: a check is ticked when done (“Towels replaced”), an inspection item passes or fails (“Shower drains”). A property has at most one template per task type and room type. Every edit makes the template’s next version.

The template is frozen on each task when it is created: the repository’s insert copies the template that applies then (the task type with the room’s type, else the task type with any) onto the task in the same statement, whoever creates it (generation, a person, an inspection after a clean, a closed ticket, a failed item). Doing it in the one statement every creation goes through means no creation path can forget it. Later edits or a deleted template never change existing tasks; a deleted template leaves its copies without a link to it.

Items are answered on the task (PUT /housekeeping_tasks/{id}/checks/{key}, housekeeping.update, with the task’s ETag, which gives the task its next version). A task cannot be done while a required item has no answer (housekeeping.checklist_incomplete), for one task or in bulk; a failed inspection item is an answer. A failed inspection item brings its follow-up in the same transaction: either a task for the room (clean, deep clean, inspection or other, origin checklist, due on the task’s day or the business date if later) or a fault report, a maintenance ticket found during the task, which also needs maintenance.manage. The follow-up is work of its own: changing the answer later keeps it.

Checklists are answered on the web. The mobile app is postponed: it shows tasks without their items, and finishing a task whose required items are open answers the conflict it shows for any refused change.

Integrations receive three thin events of the catalog, about the new object housekeeping_task (its property, and its reservation when a stay made it):

  • housekeeping_task.created@1: every new task, generated, made by a person, an inspection after a clean, a closed ticket’s follow-up or a failed checklist item’s;
  • housekeeping_task.assigned@1: a new assignee or none, by a person, in bulk, by automatic assignment or by its Undo, and a task a person creates for a member (after its created);
  • housekeeping_task.status_changed@1: every status change, including a cancellation or reinstatement that follows the task’s stay, and a withdrawn follow-up inspection.

A rescheduled task (new date or room, same status) records no event of these types: integrations read the task again when they need its date. The events carry identifiers only.

A ticket gains a priority (low, normal, high, urgent), an assignee, a scheduled date and a due date, the reporting member, the housekeeping task it was found during, and the status flow open → in progress → done, with reopening. Status changes have their own sub-resource (PUT …/maintenance_tickets/{id}/status). Closing requires a follow-up, clean or inspection, which creates that housekeeping task for the room on the business date; the room status itself never changes. Reopening withdraws that task while it is pending. Every change appends a line to the ticket’s activity (maintenance_ticket_activities): who, when and which facts, never its text; the same change is audited with a domain event.

A fault report is a ticket created from a task or a room, optionally with a new out-of-order or out-of-service block created in the same transaction through the existing block rules (the grant of its kind, no overlap, no assigned stay for out of order, not in the past). This is how a report “updates the unit’s status”: the block, not the cleanliness. The report answers the nights the new block oversells, as creating a block does.

The ticket collection is renamed from /properties/{id}/maintenance-tickets to /properties/{id}/maintenance_tickets in the major API revision 6.0.0, a clean break with no alias, taken before 1.0 together with the new status sub-resource: ADR 0016 asks for snake_case paths (finding S1 of the quality programme), and every ticket operation changes for its clients in this revision anyway. ADR 0016 records it as a dated exception that lists the three removed operations (list, create and the PATCH that also set the status). The operation identifiers are unchanged, so SDK callers only regenerate.

Client identifiers. A creation that names its own identifier (hkt_, mtk_, mph_) first looks it up in its property, to answer a repeated request with what it created. Otherwise it inserts only (on conflict (workspace_id, id) do nothing): an identifier that another property of the workspace already uses is refused with the field-id problem (photo.invalid for a photo), and the other property’s row is never touched. A fault report checks everything, including its block, before it writes anything.

Photos are stored files behind the FileStore port of core/files (re-exported by core/blocks): a stored_files row per file, by key in its workspace, and maintenance_photos the metadata of each ticket’s photo. The database keeps the bytes by default; an instance may keep them in object storage instead, behind the ObjectStore port, so that nothing paid is ever required.

  • Limits. JPEG or PNG only, recognised from the bytes (the declared type must match), at most 2,000,000 bytes after decoding and 10 photos per ticket. The upload route accepts a body of up to 3 MiB for the base64 JSON, like message attachments.
  • Structural validation (reviews of PR #289). Before FileStore.write, the whole file is walked by its grammar, without a native image library (core/blocks/jpeg.ts and png.ts):
    • JPEG (ITU-T T.81, Annex B): start of image; then tables and application segments, one frame header, one or more scans and the end of image last with nothing after it; any other marker is refused. The frame header has a valid precision, one to four components with distinct identifiers, sampling factors 1–4 and quantization tables 0–3, and Lf = 8 + 3 × Nf. Each scan header has one to four distinct components of the frame, table selectors 0–3, Ls = 6 + 2 × Ns, and the spectral selection and successive approximation its frame’s process allows (sequential, progressive or lossless). A restart interval (DRI) has exactly two bytes; restart markers appear only inside scans, only under an interval, in order RST0 to RST7 and again; stuffed FF 00 bytes appear only inside scans and never after fill bytes; each scan has data. Quantization and Huffman tables have valid classes and identifiers and internal lengths that add up exactly to their segment; a line count (DNL) has two bytes and follows the first scan.
    • PNG: signature; every chunk within the file, its type four ASCII letters with the reserved third letter uppercase, its CRC valid; no critical chunk but IHDR, PLTE, IDAT and IEND; IHDR first and once, with a valid colour type, its allowed bit depth and compression, filter and interlace methods; PLTE at most once, before IDAT, 3 to 768 bytes and a multiple of 3, no more than 2^bit depth entries in an indexed image, required there and refused in grey images; IDAT chunks consecutive; IEND last and empty with nothing after it.
    • Not validated: the entropy-coded data of a JPEG’s scans and the compressed content of a PNG’s IDAT chunks are never decoded or inflated, so a stored photo can still fail to decode. The picture may have at most 4096 pixels on a side and 16 megapixels in all (photo.dimensions_too_large), so a file that claims or compresses a huge raster under the byte limit is refused: no viewer ever decodes more than that. The apps send at most 1,600 pixels, so a real photo fits.
    • Evidence: permanent regressions for each rule; the review’s 25 grammar probes pass; an independent corpus of 1,891 mutations of real encoder output (baseline, progressive and restart JPEGs with Exif; RGB, indexed, 1-bit indexed, RGBA and grey PNGs) has no accepted input that two independent structural checkers refuse, no exception and no hang.
  • Privacy. The server removes JPEG APP1–APP15 and comment segments (Exif with GPS, XMP, IPTC) and PNG text, Exif and time chunks before storing. It keeps the two application segments that only describe colour, an APP2 ICC profile (a phone’s Display P3) and APP14 Adobe (CMYK), which carry no place or person. The clients downscale before sending (web: canvas to at most 1,600 px, JPEG quality 0.8; mobile: the same through the image manipulator), which also bakes the orientation that the stripped Exif would have carried.
  • Access. Reading a photo needs maintenance.read at the ticket’s property; adding or removing one needs maintenance.manage. The bytes are answered with their sniffed type, Cache-Control: private, max-age=300, X-Content-Type-Options: nosniff and Content-Disposition: inline.
  • Database size. The free tiers hold 0.5 GB (Neon) or 500 MB (Supabase) for the whole database. A downscaled photo is typically 150–400 kB: about 1,500 photos fill half of a free database. The installation guide says so and recommends a paid database or object storage for busy properties, or to keep photos in object storage (below).
  • Retention. Photos live as long as their ticket, and members with maintenance.manage remove them one by one; removing a photo removes its stored file in the same transaction (an object’s bytes go with the orphan collection below). The bytes are kept by key in their workspace, not tied to a property: deleting a property (which the product does not offer; tests do) cascades its tickets and photo rows but leaves their bytes in stored_files until the workspace is deleted. Keeping the FileStore port free of properties is deliberate, so that object storage can replace it by key. Automatic retention (for example removing photos of tickets closed for a year) is planned.
  • Where the bytes are. stored_files.location is database or object; a check constraint gives every row in the database its content and none to the others, and keeps byte_size equal to the content’s length. An object’s name is the instance’s identifier, the workspace’s UUID and the file’s key, separated by slashes. A bucket or store belongs to one instance: the prefix keeps a second instance that shares it by mistake (staging and production on one R2 bucket, one Blob store linked to two projects) from collecting this one’s objects as orphans. Reads follow each row’s location, so an instance whose files are partly in each place serves all of them. A read checks the object’s length against the row’s byte_size and refuses a mismatch as a failure that a retry does not fix. A read that finds the object missing reads the row again once: a move back may have committed and deleted the object in between, and the row then holds the bytes.
  • Adapters (packages/storage, AGPL, importing core only): an S3-compatible bucket over aws4fetch (Signature Version 4, the body’s SHA-256 signed; AWS S3, Cloudflare R2, MinIO, Supabase Storage’s S3 endpoint), and a private Vercel Blob store over @vercel/blob, whose private stores are generally available since SDK 2.3 (access: "private", no random suffix, overwrite allowed), loaded on the store’s first call. Each S3 request and each Vercel call ends after 10 seconds: the file store calls object storage inside its unit of work, which the database ends after 30 seconds between two statements. Reading the object after the commit would free the connection sooner, but the port’s read returns the bytes inside the use case’s transaction, and the bound keeps the connection’s hold short enough. The Vercel adapter treats the plain BlobError that the SDK’s get throws for any failed answer, a 5xx or a 429 among them, as retryable. It is tested against the SDK’s interface only; CI has no Vercel account. Configuration: GUESTCRAFT_FILE_STORAGE=database|s3|vercel_blob, the GUESTCRAFT_S3_* settings or Vercel’s BLOB_READ_WRITE_TOKEN (or BLOB_STORE_ID with the deployment’s OIDC token), and GUESTCRAFT_FILE_WRITES=object|database.
  • Write order. An object cannot join the transaction, so the file store puts the object first, then inserts its row; a transaction that rolls back leaves an object without a row. Removing a file deletes only its row: deleting the object inside a transaction that may still roll back would leave a row without its bytes. Keys are fresh identifiers; a repeated key would replace the object before the insert fails on the primary key, where the database alone changes nothing.
  • Orphan collection. A system job (files.collect_orphans, a 90-second timeout, so that it fits the 120-second hosted drain like every job, which a catalog test checks), on the resident worker every ten minutes and from each hosted drain, does nothing within an hour of its last run, so object storage sees at most one listing an hour (free plans count listings: Vercel Blob’s advanced operations). A run lists one page of at most 1,000 of this instance’s objects (its prefix only) from a cursor persisted per instance in guestcraft_meta.object_sweeps, wrapping to the first object after the last page, and deletes the objects older than 24 hours whose name has no stored_files row in object storage. It checks and deletes 50 names per locked transaction and stores the cursor with the last chunk, so that no transaction waits long between statements; a run that fails keeps the chunks it deleted and repeats the page. A SECURITY DEFINER function, orphaned_objects(text, text[]), executable by the worker only, answers which names are orphans: it keeps the objects of archived workspaces, for a restore, deletes those of workspaces that no longer exist, and never returns a name outside the given instance’s prefix or that is not the instance, a workspace UUID and a key. It answers nothing when the instance is not the database’s, and nothing when its owner does not bypass row-level security, because it would then see no row and take every object for an orphan. The job runs only on the instance that owns background writes and logs counts only.
  • One race closed by a lock. The grace period protects files being written, whose keys are new. Only the move command gives an existing object name a row again; it takes the sweep row FOR SHARE before writing objects, and the collection checks and deletes under FOR UPDATE of the same row, so an object found orphaned is deleted before any row can name it, or found named. A test holds the collection’s lock and shows that the move waits, then that its object stays.
  • Moving. files:move -- --to object|database (owner URL, server’s migration image) moves the bytes in transactions of 20 locked rows. To object storage: each object is written, then its row loses its content. Back: each object is read into its row, and deleted after the commit; an object left by a run that stopped in between is collected later. Runtimes set GUESTCRAFT_FILE_WRITES=database during a move back, so new files no longer go to object storage while it is still read. A stopped run keeps its committed batches and a rerun continues; a row whose object is missing stops the move back with a fixed message, which gives the query that lists the rows among which it is.
  • Access is unchanged. Downloads stay proxied by the API with the same permission checks and headers; no object is public and no signed URL is issued.
  • A removed photo’s bytes stay in object storage for at least 24 hours, and until the collection reaches them: one page an hour, so a full pass over a store of N of the instance’s objects takes about N / 1,000 hours.
  • A bucket or store serves one instance. Another instance’s objects in it are never listed nor deleted, but sharing one is not a supported setup.
  • MinIO’s project no longer publishes minio/minio images; the tests and the optional Compose service use the community build of the same sources, pgsty/minio, pinned by digest.

The app adds “My tasks” (tasks assigned to the signed-in member for the property and day), one-tap status changes with a 10-second Undo, task and room detail, and fault reports with a photo from the camera or the library. Every write goes through one small submit function (apps/mobile/src/lib/housekeeping-writes.ts), which routes it through the mobile offline layer’s outbox (apps/mobile/src/offline/, ADR 0041): task status and notes (housekeeping_tasks.change, sent with the task’s version as If-Match), fault reports (maintenance_tickets.create, under the report’s client mtk_ identifier) and their photos (maintenance_tickets.photo, under client mph_ identifiers, keyed by their ticket so they follow it), and room status through that layer’s own operation with housekeeping_version. A report and all its photos are written to SQLite in one atomic insert, in order, before anything is sent, so an app killed at any point keeps all of them or none, and never a sent report whose photos were only in memory. Each entry keeps its key or version before it is sent and on every replay: a repeated creation answers the object already made, a stale version answers 412, and a refused change is held for a person’s decision, never resent or overwritten. Entries are partitioned by instance, account and workspace and wiped with them.

  • Reservation writes do a little more work: one read of the generated days, the settings and the reservation’s generated tasks. Writes that change nothing about tasks write nothing more.
  • Tasks of stays booked before this change appear when their day is generated.
  • A generated day older than the business date is left as it was: its pending tasks stay visible as history and are no longer rescheduled.
  • The photo bytes count against the database size; the ADR and the installation guide say how much.

Enforced by: the unique (workspace_id, property_id, origin_key) constraint and real PostgreSQL tests; unit tests of the planner, the synchronization and the lifecycle; route tests of every operation’s exact problem list; the web and mobile screen tests; a browser test of the board.

  • Generate every task eagerly for whole stays. Simple to read, but a settings change would rewrite thousands of rows in one request, and boards months ahead would fill up.
  • Generate from domain events in a job. Decoupled, but adds a delay, a new job and the Vercel pump path, and a reader could see a stale board right after a change. Synchronizing in the reservation’s transaction is immediate and still independent of who changed the reservation.
  • Generate inside the night audit. Excluded: the night audit belongs to its own track, and a property that has not run it would have no tasks.
  • Object storage as the only photo store. Needs an account or a self-hosted service, which the zero-cost rule does not allow as a requirement: it is an option, and the database stays the default.
  • Signed URLs for downloads. They would spare the server the bytes, but a URL handed out stays valid after the member loses access until it expires, and Vercel Blob’s private stores serve through the deployment anyway. Proxied downloads keep one permission check per read.
  • Minutes instead of credits. A time model needs room sizes, travel and breaks to be useful; credits let a property weigh its own work without one, and start times still order it.
  • Freezing checklists in each creation path. Five paths create tasks; any one that forgot would leave a task without its checklist. The repository’s single insert statement cannot.