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
Context
Section titled “Context”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).
Decision
Section titled “Decision”Generated tasks follow the stays
Section titled “Generated tasks follow the stays”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.
Readiness and the room status
Section titled “Readiness and the room status”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.
Permissions and assignment
Section titled “Permissions and assignment”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.
Planning data and automatic assignment
Section titled “Planning data and automatic assignment”The head housekeeper plans a day with three pieces of data:
- Sections. A room’s existing
flooris 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.updateat 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- Saving needs
housekeeping.assign, the same grant as assigning.
- Saving needs
- 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 needshousekeeping.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_stafforno_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.
Checklists
Section titled “Checklists”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.
Webhook events of tasks
Section titled “Webhook events of tasks”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 itscreated);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.
Maintenance tickets
Section titled “Maintenance tickets”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
Section titled “Photos”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.tsandpng.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; stuffedFF 00bytes 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 depthentries 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.
- 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
- 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.readat the ticket’s property; adding or removing one needsmaintenance.manage. The bytes are answered with their sniffed type,Cache-Control: private, max-age=300,X-Content-Type-Options: nosniffandContent-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.manageremove 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 instored_filesuntil the workspace is deleted. Keeping theFileStoreport 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.
Object storage
Section titled “Object storage”- Where the bytes are.
stored_files.locationisdatabaseorobject; a check constraint gives every row in the database itscontentand none to the others, and keepsbyte_sizeequal 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’sbyte_sizeand 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, importingcoreonly): 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 plainBlobErrorthat the SDK’sgetthrows 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, theGUESTCRAFT_S3_*settings or Vercel’sBLOB_READ_WRITE_TOKEN(orBLOB_STORE_IDwith the deployment’s OIDC token), andGUESTCRAFT_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 inguestcraft_meta.object_sweeps, wrapping to the first object after the last page, and deletes the objects older than 24 hours whose name has nostored_filesrow 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 SHAREbefore writing objects, and the collection checks and deletes underFOR UPDATEof 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 setGUESTCRAFT_FILE_WRITES=databaseduring 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.
What object storage changes
Section titled “What object storage changes”- 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/minioimages; the tests and the optional Compose service use the community build of the same sources,pgsty/minio, pinned by digest.
Mobile and offline
Section titled “Mobile and offline”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.
Consequences
Section titled “Consequences”- 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.
Alternatives considered
Section titled “Alternatives considered”- 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.