Engineering reference: the housekeeping dispatcher architecture
The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the allocation pass, and the state model.
Key takeaways
- Single region, single account. Every resource is regional; nothing is global except the IAM roles.
- 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
- 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
- One Bedrock model, called once, with a JSON schema it must fill or leave null.
- Nothing always-on: no instance, no container, no provisioned capacity.
The system, by service name
- Compute
- Database
- Networking
- Front-end & mobile
- People
Region and account
- Region:
us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one. - Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A
devand aprodstack in the same account, with distinct resource prefixes, is the right size here. - Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.
Lambda inventory
| Function | Trigger | Does | Timeout / memory |
|---|---|---|---|
hk-allocate | EventBridge, each morning | Groups by floor, allocates blocks by measured minutes against the four-week balance, orders by required-by time | 120s / 1024 MB |
hk-state | API, from the phones | Records state transitions with a person and a timestamp; creates maintenance jobs | 10s / 512 MB |
hk-balance | EventBridge, nightly | Updates per-room median durations and each person’s running workload total | 60s / 512 MB |
Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.
IAM, scoped
| Role | Allowed | On |
|---|---|---|
hk-allocate-role | dynamodb:Query, dynamodb:UpdateItem | Read-only on the PMS mirror; both tables |
hk-state-role | dynamodb:UpdateItem, sqs:SendMessage | Rooms; the maintenance queue |
hk-balance-role | dynamodb:Query, dynamodb:UpdateItem | Both tables |
No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.
DynamoDB schemas
Table: rooms
PK room_id S 214
floor N 2
state S dirty | in_progress | cleaned | inspected | ooo
state_set_by S a person, always; never a timer
state_set_at S
dnd_since S set from the door or the PMS; no override exists
hours_unentered N computed; a welfare signal past a threshold
required_by S from the arrival, or null
work_type S checkout | stayover | refresh
median_minutes N across all housekeepers, not one
requests L cot, accessibility, allergy
flags L [{issue, photo_key, at, by}]
`median_minutes` is deliberately person-independent. Using an
individual’s times makes a fast worker permanently own the hard rooms.
Table: workload
PK person_id S
SK date S
minutes_allocated N
minutes_actual N from the state transitions
rooms N recorded, but not the allocation unit
hard_rooms N count above a difficulty threshold
floor N for the rotation
rolling_28d N the number the balance is computed on
`rolling_28d` is shown to each person. A fairness total that only a
supervisor can see is a claim rather than a mechanism.
Inbound and outbound
- Arrivals, checkouts and do-not-disturb come from the property management system, read only. This system writes nothing back to it.
- State changes come from the phones, one tap, always carrying the person and the time. There is no path that sets a state without a person.
- Allocation runs once in the morning and adjusts by exception. A list that reorders itself under somebody is not usable.
- Maintenance flags create jobs in whatever the maintenance process is, not notes in this system. A report that goes nowhere stops being made.
The model call
- There is no model in this system. Allocation is grouping and sorting; difficulty is a median.
- The tempting use is predicting when a room will be ready. Part 5 is about why no prediction appears anywhere: it will be quoted to a guest as a promise.
- A second tempting use is scoring housekeeper productivity. It would make everybody mark rooms inaccurately, which destroys the timing data the fairness depends on.
- A defensible use is classifying photographed maintenance issues into trades, so the job reaches the right person.
- The cost page assumes none, which is why the bill is fixed.
Things worth knowing before you build it
- Allocate by measured minutes, never by room count. Fourteen checkouts and fourteen stayovers differ by about two and a half hours.
- Group by floor before applying priority. A strictly priority-ordered list costs the better part of an hour per person in movement.
- Give do-not-disturb no override in any code path. On a busy day an override exists to be used, which is exactly when it should not be.
- Never set a state from a timer or an inference. One tap from a person, or the state does not change.
- Publish the rolling workload balance to the team. A fairness figure only the supervisor sees does not produce the trust the system depends on.
That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.
All posts