03 · The client portal
The thing it has to beat is a text message
A second product, live since October 2026 at portal.donedone.services. Our clients' facility staff send maintenance requests from their phone, watch what happens to them, and talk to us on the request itself — while the work runs in DUDE, where it always did.
What it is
One address, for the person who finds the leak
A named person at a client — an office manager, a facility contact — opens one address, types their work email, gets a sign-in link, and lands on their building's page. They describe what's broken, add up to five photos, and submit. Seconds later the request is sitting in our triage queue inside DUDE.
From there the portal translates DUDE's fifteen internal job statuses into six plain-language states the client can read, emails them at the few moments actually worth an email, and carries messages both ways. It never writes a job status. It reads.
That restraint is the whole design. DUDE stays the system of record, triage happens where the work happens, and the portal is a window with a letterbox in it — because building triage in both places would mean double entry across two systems that can disagree.
The problem
There are no captive users
Nobody has to use this. A client who finds it confusing picks up the phone and texts me instead — and I have spent money to change nothing. That sentence is in the specification, near the top, because it decides every argument further down.
The facility contact is the make-or-break user. If submitting a request takes more effort than sending you a text, they will send a text.
the portal specification, §4
So the submit button is above the fold on the facility home screen, verified at phone width before the screen was signed off. The design target for a whole submission is 45 seconds on a phone; the pass-or-fail threshold written into the spec is 60, measured with a real person rather than estimated.
The second problem is the one I feel every week: the status-chasing call. The email the portal sends when a visit gets scheduled exists specifically to replace "any update on that?" — and the spec is blunt that if the status model were built carelessly, that email would silently never send and nothing would look broken.
The honest version of why it exists
Convenience alone was never expected to carry this. A client who texts me and gets an answer is already well served. The deeper problem is that we could not prove what we know about a building: there were no maintenance spreadsheets, no equipment lists, no facility documents to start from, and no client under a preventative-maintenance contract. The portal's first job is not to display information — it is to be the reason the information gets created in the first place.
The client's side
Six screens, no password
Signing in
magic link onlyNo passwords, no self-service signup, no inviting a colleague. Addresses are registered by us; a link arrives, is good once, and expires in about fifteen minutes. The session then lasts thirty days and rolls forward, and whether the account is still active is re-checked on every single request rather than only at sign-in — so switching someone off ends their session on every device they own, on its next request.
The sign-in page looks identical whether or not the address exists, and an expired link and an already-used link produce the same message — the difference would tell an attacker whether a token was real. Asking for a second link while one is still alive is refused, so a stranger typing your address can't invalidate the link already sitting in your inbox.
Submitting a request
Five fields, one of them optional twice over: what needs attention, where is it, anything else we should know, how urgent (Low, Medium, Emergency), and up to five photos straight from the camera roll. The emergency phone number sits above the fields with plain text telling people not to use a form for anything urgent — and choosing Emergency surfaces the number again, inline.
The response-time target is computed when the page renders rather than printed as a constant, against real business hours. A request filed at four on a Friday afternoon shows a next-business-morning target, not a cheerful "4 hours" that nobody intends to honour.
Submitting is the slowest thing the client does — a measured real submission took 4.8 seconds, almost all of it three phone photos being converted and stripped. So the button disables itself and says "Sending…", which was added after a live pass proved that a twenty-second submit on a mobile browser looks exactly like a broken page.
What happens to the photos
Every image is converted to JPEG, capped on its long edge, and has its EXIF metadata stripped — including the GPS coordinates of the client's building. Photos straight off an iPhone work, including the HDR format that is technically not a plain JPEG at all.
A file that cannot be decoded for any reason — damaged, truncated, a garbage metadata header — now gets a plain-words refusal with everything the client typed still on the page. Before that change each of those answered with a server error and lost the request.
Watching it, and talking to us
The request list shows this building's requests with a scheduled date that always renders — a real date, or the literal word "Pending" — because a blank space invites the client to guess. The detail page shows what they submitted, where it has got to, a note when we are waiting on them, and when it closes: our summary under "What we did", then the photos we picked from the job, then their own.
At the bottom of every open request is a message thread. The client writes; we reply from inside DUDE; both sides see author and timestamp. When the request closes the thread goes read-only and says so.
The six states a client sees
| State | What it means to them |
|---|---|
| Submitted | It's in their system |
| Scheduled | Work is on the calendar |
| In progress | They're working on it |
| Review | Work's done; they're wrapping up |
| Closed | Done — here's what they did |
| Cancelled | — |
Those six are derived from DUDE's fifteen job statuses by a map that is checked at start-up to prove it covers every one of them. If DUDE ever reports a status the map doesn't recognise, the client's view holds its last state rather than guessing, and a counter on our admin screen records how often that happened.
Five emails, and three deliberate silences. The client hears from us when the request is received, when a visit is planned (and again if it moves), when we are waiting on them, when it closes, and when we reply to a message. They are deliberately not emailed on "in progress", on "review", or on cancellation — because if we ever cancel something for a reason the client doesn't already know, that is a phone call, not an email.
Our side
A request becomes a job in one screen
The triage queue, inside DUDE
Each request is a row with the facility, the number, the title, how urgent, when it arrived, and the client's own words — plus a link straight to the folder their photos landed in. Four moves: link it to an existing job, create a job from it with the scope of work pre-filled from what they wrote, cancel it with a reason they'll read, or close it with a summary they'll read.
Linking and creating are both primary paths, not a main case and a fallback — because the ordinary sequence is to turn the first request into a job and then hang the next two off it. A job you just created appears first in the link picker for exactly that reason.
A list that exists because the client can now see us
Under the queue sits Stuck in Review: requests whose job is finished but which nobody has closed out. It exists purely because the client's view now has a "Review" state.
A request parked in Review for two weeks is a visible broken promise where it used to be invisible. Giving the client a window means building ourselves the list that shows what they are looking at.
Closing with photos we choose
The close page takes a required summary plus photos ticked by name out of that job's own Drive picture folders. Every tick is re-checked against a fresh listing when the form is submitted, so a stale or forged pick can't reach the client. The portal then fetches those bytes on its next hourly pass and runs them through the exact same pipeline as a client upload — re-encoded, capped, metadata stripped.
A ticked photo can't be un-ticked afterwards. Moving it to the Drive trash isn't enough, because Drive still serves trashed files to anything holding their id — which we found out by measuring rather than assuming.
Replying
A client's message reaches DUDE within seconds and shows on the queue row and on the job page — including the technician's own job page, read-only, so the person going to the building can read what the client said. We reply from DUDE, never from the portal. The reply reaches the client at the next top of the hour.
That lag is designed, not tolerated: DUDE never calls the portal. Everything crosses the boundary on the portal's own schedule, so if the portal is down, DUDE does not notice.
Architecture
Why it is a separate application
DUDE's "not customer-facing" rule is not a policy, it is a line of code: every sign-in is checked against a signed claim that the account belongs to our Workspace domain. There is exactly one domain-locked way in, and that is why every route in DUDE is safe.
Add a second way in — a client login — and each route's safety starts depending on which path ran, across every screen ever built. The fence stops being structural and becomes something to remember. So the portal is its own application, its own repository, its own cloud project.
There is a blunter version too: DUDE deploys its working tree from the repository root, so code living together rides in each other's production images. With no continuous integration to enforce anything, the repository boundary is the only isolation that cannot be crossed by accident.
The decision carries written conditions that would reverse it, and one of them is instructive: a count of times cross-repository drift causes a real build defect — where the count only starts once drift survives into shipped code. A drift caught by the re-checking rule deliberately doesn't count, because counting it would trip the reversal on the evidence that the mitigation works.
The fence
One database, many clients, one place that knows which
Every client lives in one application against one database, fenced at the row level — not a separate deployment each. Four reasons, all written down before a line was built: it is the shape DUDE already is; per-client deployments mean N deploys per change forever with no developer on staff; onboarding a client becomes configuration rather than a release; and it stays reversible, because every tenant table reaches the client id within two hops.
The accepted risk is stated just as plainly: one bug in the fence exposes every client, where separate deployments would expose one. It is only defensible because the fence is a single testable thing.
The rule the whole product rests on
The client id comes from the session. Never from the request. Never from the Host header. Every tenant read goes through one module, which puts the scope condition into the SQL itself — no screen anywhere composes its own query against a tenant table.
The fence has one home, and the home is policed
The test suite fails if tenant SQL appears in any other module — and keeps an explicit allowlist of the unscoped functions inside that one module, because a fence with a single home is defeated by quietly adding an unscoped function to it.
No screen may take a file id from the URL
Every client-facing route must have a cross-tenant test case, and no route is allowed to declare a photo, document or comment id as a URL parameter. Links to files are minted inside the already-scoped page render, so there is no per-file endpoint to attack — a decision that is reversible, but cannot reverse quietly.
The fence is removed on purpose, to watch the suite go red
A recorded mutation check deletes the scoping and proves the tests catch it, with counts. A guard nobody has watched fail is a guard nobody has tested.
Every case asserts 404, not 403 — a 403 confirms the record exists, which is itself a small leak. And the document holding those cases carries the sentence that does the real work: a case that accepts either has not been written.
It also has a section titled "what this file does not prove", naming the gaps out loud: leaks that involve no route at all, what ends up in logs, timing signals, and how narrow the email case is — "email is the one channel where a leak cannot be un-sent, so read the narrowness as narrowness, not comfort."
There is exactly one sanctioned exception. An administrator from our side may pick a client, because they are already authorised for all of them — it lives in one function that asserts administrator first. Admin doesn't bypass the fence; it chooses where the fence points. Impersonating a client was considered and rejected on the record, because it would mean a client-facing route accepting a client id that did not come from a client session.
The seam
Five endpoints, all in one direction
The two systems talk over five narrow machine-to-machine calls, every one of them initiated by the portal and authenticated with a Google-signed token bound to a named service identity. DUDE stores and serves raw facts and derives nothing; the plain-language state model has exactly one home, on the portal side.
| Call | What crosses |
|---|---|
| Push a request | Title, facility, priority, type, time, and the client's location and description. Creates a queue row — not a job. |
| Pull status | Raw facts per request, up to a hundred at a time. The answer now carries ten keys; "unknown" is a real answer. |
| Push a photo | One client photo per call, filed by DUDE into a per-request folder under its own Drive root. |
| Fetch a resolution photo | The bytes of one photo we picked at the close. The stored pick is itself the allowlist. |
| Push a comment | One client message, idempotent on its id. DUDE accepts a comment on a request it has already closed, so nothing gets stranded. |
Timeouts are split by who is waiting. Twenty seconds when a person is watching a submit; forty-five for anything the hourly machine does. The parameter has no default, so the next caller has to say which kind it is.
The clients
One live, one next
Fields of Fun
Live since September 2026 · first genuine request 21 September
A licensed child care centre in Cedar Rapids — and our sister company, which I should say plainly. Classrooms, playgrounds with impact surfacing, a commercial kitchen, and a building that is full of children from half six in the morning until six at night.
It was chosen as the pilot precisely because it is awkward. The facility survey had been written around a light-industrial warehouse, so entire classes of equipment here — play structures, anti-scald fittings at child height, kitchen equipment, state child care licensing alongside fire and health inspectors — had no prompt at all. That is the pilot working as intended: a real building with something nobody thought to ask about.
It imposes two site rules no other client would: no photograph may contain a child, and disruptive work happens outside operating hours.
What a sister company cannot test is written down next to what it can: an internal user adopts the portal because they were asked to. It proves the software works. It proves nothing about whether a paying client would bother.
BioVentures
Not yet live — gated on its user list
The next tenant, and the reason much of the platform looks the way it does. Their requirements turned four client-facing states into six, renamed the priorities, added an emergency target measured in business hours with an internal alert attached, made response targets per-client configuration, raised the photo limit from three to five, and moved the whole platform onto its own domain.
All of it was applied as template changes rather than one client's special case — while no client was live and the change was still free.
They also invert the platform's founding premise. We built it on the assumption that a client has no documentation to start from; this one arrives with records of its own. That turns the first phase of the work from capture into curation.
One of the two tenants in production is not a client. Our own internal test tenant is an ordinary row with no special flag, used to run every client-facing live pass — because there is no probing production with a cross-tenant test. Giving it a flag would have meant building a second product.
The hard parts
Six things that went wrong
1. A trailing newline killed every sign-in email on day one
- What happened
- The email provider's key had been stored using a shell command that silently appends a newline — an illegal character in an HTTP header — so every send died inside the library. One log marker found it in a single query.
- What it cost
- Worse than the outage: the failure path was noisy in a way that forced us to treat the key as compromised and reissue it.
- Rules
- Secrets are entered with a command that writes exactly what you give it. Configuration strips them on read. And the error log redacts the key, pinned by a named test.
2. A real iPhone photo was refused by a pipeline that passed every test
- What happened
- A genuine 1.2 MB iPhone photo bounced at the first live pass. iPhone HDR images are a container format, and the imaging library reports the container rather than the JPEG inside it — so the earlier magic-byte probe could never have seen it.
- Rule
- A live pass uses files a real phone produced. And a pipeline must not depend on a library's list of exception classes — which is why every decode failure of every kind now answers in plain words with the client's typing preserved.
3. A timeout moved three times, and the third time changed the design
- What happened
- The first value was sized from a 3.4-second cold request. The next live pass measured 12–13 seconds. The one after hit 33.5 seconds at five in the morning, returned an error, and was rescued by an automatic retry.
- Rule
- Stop raising the number; split it by who is waiting. Keeping the other service permanently warm was priced and declined, with the condition that would reverse that written down: a paying client complaining about a slow submit.
4. Two overlapping passes, one email sent twice
- What happened
- The scheduler recorded a failure on an hourly pass that the platform itself logged as having succeeded in just over two minutes — and retried it while the first attempt was still running. Both passes read the same ticket, and a closing email went out twice.
- Why
- The send decides from the state each pass read, and the send happens before the log write, so nothing serialises two overlapping passes. Comment emails are narrower by accident of design — a comment is inserted under a unique key, so only the pass that wins the insert sends it the first time — but a retried pass can still double one that is owed.
- Rule
- An idempotent insert is what makes a send safe; everything else needs a lock. It was ordered as the very next slice rather than filed as a backlog item, and that slice is in flight now.
5. A migration that read fine would have broken the rollback
- What happened
- A planned migration was probed on a scratch database at the previous schema, running the previous version's literal statement — and narrowing one index turned out to destroy the database's ability to use it for an existing conflict clause. Every notification log write would have failed during the deploy window, and under any rollback.
- Rule
- The shape changed instead of the index, and a test now runs the previous slice's literal statement against the new schema on every single run. Reviewing the text of a migration would never have found this.
6. The fence caught the person who built it
- What happened
- At a live pass, the first thing I saw on opening a request was Not Found — because I was signed in as one tenant and opening another tenant's request.
- Why it's here
- 404, not 403, exactly as specified. It is the only time in the record the tenant fence was exercised by accident on production, and it held.
A seventh is worth a line because of how it was fixed: a build review found that an authentication library echoed a provider's raw error text onto an unauthenticated page, while our alerting matched on log text — so someone could have shaped a log line. The reviewer then checked the other repository and found the same hazard in DUDE's equivalent screen. Two repos, two code paths, hand-carried across as a tracked item rather than assumed fixed. That is the cost of the two-repository decision, paid exactly where it was predicted.
Timeline
Planning in August, live in October
- 2026-08-02First commit — a planning baseline, not code. Two founding facts established: no source data exists anywhere, and no client is under a maintenance contract.
- 2026-08-23The BioVentures scope document. Six states, new priority names, five photos, the emergency target, the separate domain — all folded into the template while nothing was live.
- 2026-08-24Two repositories, deliberately. The decision is recorded with the conditions that would reverse it.
- 2026-08-26The build hold is lifted, in writing. Planning had run for three and a half weeks before a line of application code was permitted.
- 2026-08-27The first two slices ship on the same day. Schema, the fence and its structural tests, magic-link sign-in, the whole client-facing loop, the photo pipeline, the business-hours clock.
- 2026-09-01DUDE's half lands. Three firsts for DUDE in one slice: it serves machine-caller JSON, an outside application authenticates into it, and it stores rows another system originated.
- 2026-09-02The real round trip. Push at submit, hourly status pull, six-state derivation. The stub is retired.
- 2026-09-08Notifications, and a nine-day soak after them. 144 retries across exactly 72 hourly passes, the bound observed to the minute; sixteen alert incidents, every one on a real marker, zero false firings.
- 2026-09-18Fields of Fun onboarded. Not a slice — a runbook activity, because adding a client is configuration.
- 2026-09-21The first genuine client request. The day before, a coordinated test submission had proved the mailbox and the sign-in. This one was somebody's actual broken thing.
- 2026-10-08The domain goes live at portal.donedone.services. Certificate issued in about eleven minutes. On day one a consumer Gmail account filed the sign-in link in the inbox, with every mail authentication check passing.
- 2026-10-09The client's words and photos start reaching the job page. And a dead job no longer cancels the client's request — it steps back and waits.
- 2026-10-15The first inbound content path: our photos reach the client. Through the same pipeline as their own uploads, metadata stripped identically.
- 2026-10-21Both halves of request comments ship the same day. The first thing a client can write after the original submission.
- 2026-10-26An emergency dependency upgrade in both products. Twenty-eight advisories against the imaging library every client photo passes through, three of them critical.
By the numbers
A small application with a large test suite
Nearly three lines of test for every line of application code — about 7,400 lines all told once templates, SQL and the deploy script are counted. Twelve slices shipped between 27 August and 21 October 2026, and a thirteenth is in cadence now. Figures as of the last shipped slice, 21 October 2026.
What isn't built yet. The facility wiki, the maintenance calendar and the preventative-maintenance schedules are specified and scheduled, and none of them exist. What is live is the request loop, the notifications, our triage door and the two-way thread. The wiki is the one piece where the portal would become a system of record rather than a window — because that content has no upstream source anywhere, and has to be created by walking the building.
DUDE