The first version of this was a suggestion box, and it was ours. Seven request types, four statuses, a table you could sort. It worked in the sense that requests landed somewhere, and failed in every sense that mattered afterwards: the record had a status and an assignee and nowhere at all to record who had agreed to the work.
It had the shape of a tool built to be finished quickly. The whole form was joined into a single delimited string in the browser and split apart on the server, eleven parts or a rejection — an encoding that any requester could break by typing the delimiter into a description box. The management screen was gated to one named account, so triage had exactly one bottleneck and no cover when that person was out.
The requests that mattered most were the ones the suggestion box was worst at. A project with a sponsor, a budget line and an executive sign-off needs somewhere to put the sponsor, the budget line and the sign-off.
- Seven request types and four statuses, with no field for an approval
- Form data joined into one delimited string, parsed on a fixed part count
- One account could see the management view, so triage had one bottleneck
- Nothing recorded who had said yes, or when, or on what basis
A full intake record — eleven sections on the form, forty-two fields, from problem statement and success criteria through hosting environment, data sensitivity, rollback plan and post-go-live support owner — sitting behind a board with seven states, from submitted to done.
Both editors, the requester's and the board's, write through a single field map. One dictionary defines which keys are writable and which column each one lands in, and both update paths build their SET clause from it. That is a boring decision and it is the reason the two views never drifted apart: adding a field to the form makes it editable from both sides at once, or from neither.
- Eleven form sections — ten intake sections plus approvals — over forty-two field-to-column pairs in one shared write map
- A board in seven states, plus an archived view for anything closed more than thirty days
- A comment thread per request, with the full submission stored as the first comment
- Changing priority needs a session; changing status needs an explicit grant on the page
- Requests closed and reopened restamp their close date rather than keeping a stale one
Editing without an account, voting with one
Submission has to work for people who have no login at all — the intake page carries no session check, and an unauthenticated submitter is recorded as anonymous — because issuing accounts to occasional requesters is its own small administrative disaster. So submission is open, and each new request mints a long random token that is emailed to the requester as a private link. They can reopen their own request from that link, edit it and add comments, without ever authenticating.
A link like that is a credential, and the record behind it is guarded accordingly. The lookup runs against an indexed hash column, so the value in the URL is never what the query matches on. The board's own detail read strips the token columns out of the record before it reaches the browser, so a colleague with board access cannot open a request and harvest somebody else's private link. A token presented against a request it does not resolve to is refused. And the link stops working the moment the request is marked done, on the read path and the write path both, which is also why the status-change email quietly drops the link on that one transition rather than sending a dead one.
Approving is deliberately not like that. A vote is a decision of record, so it requires a signed-in session, and the server checks three things before it counts: that the user is authenticated, that the request is still in submitted status, and that the caller is named in the assignment list for that specific role. The browser hides the buttons too, but that is decoration — the checks that matter run server-side, against the same role table the configuration screen writes to.
The tallying rule is short and has teeth. Three roles must approve for a request to become approved. Any single denial closes it immediately, without waiting for the other two to vote, and because the vote endpoint refuses anything that has left submitted status, that denial is final at the voting layer. The board offers no status control at all on a denied card, so reopening one is an explicit call to the privileged status endpoint rather than a quiet re-vote.
- Private links found by hash, never by the raw value in the URL
- The token columns stripped from the board's detail read of the record
- A token presented against a request it does not resolve to is refused
- The link dies on completion — on read and on write, not just in the UI
- A vote needs a session, a named role assignment, and a request still open for voting
- One denial closes the request without waiting for the remaining roles
The board mirrors into an external issue tracker so engineering can work where it already works, and every part of that is written on the assumption the tracker will be unreachable at the wrong moment.
The board read never touches it. It returns database rows immediately; synchronising is a separate, explicit action. On submission, the full intake detail is written to the local comment thread first and the external round trip happens after, inside a guard — so a tracker outage costs you an issue number, not a request. If the push does succeed, the external comment id is written back onto the local row, which is what stops the next inbound sync importing the same text a second time.
The most useful fix on this project was the least impressive. The poller catches its own exceptions and returns a result object rather than raising, and the sync endpoint used to treat that as success. The button spun, said it was done, and the board never changed. It now inspects the flag and returns an error with the underlying reason attached, and per-item failures are collected and shown rather than averaged away into a clean green message.
Conflicts have a defined winner instead of a race. A status set by the voting system outranks anything the external board says — the inbound sweep will update the branch link and the priority on such a request but refuses to touch its status, because an approval reverted by a poller is worse than one that is briefly out of date. Priority is genuinely two-way, with the two systems using different vocabularies and an explicit translation in both directions: the external value wins when there is one, and the local value is pushed outward when there is not.
- The board renders from the database and never blocks on the integration
- Submission survives an unreachable tracker; the request is saved, emailed and visible regardless
- Issue creation reuses an existing mapping rather than creating a duplicate
- Comments deduplicated on their external id regardless of which side wrote them
- A missing optional field on the remote board logs and continues; a missing required one raises
- Every outbound call bounded by a timeout, with both list APIs paginated
Requests have an owner, a state and an audit trail. The three sign-offs that used to happen in a corridor are three rows with a name, a decision and a timestamp, and the tally — not a person — is what moves a request out of submitted. The board's own status control does not even appear until a record is past that point.
The details that made it usable are mostly bookkeeping. Status-change and approval-vote emails rebuild the requester's private link from the stored token and carry it in the body, so nobody has to dig out the original message — except on completion, when the link is dead and is deliberately left out. Close dates are reconciled on every board load from whichever path actually set the status — the interface, the approval tally, or the inbound sync — rather than trusting any one of them to remember, and anything closed for a month moves to an archived view so the board shows current work instead of history.
The schema maintains itself, too. Every operation opens by ensuring its tables, columns and role rows exist, which is how four columns were added after go-live on a server with no migration tooling. It is not the approach we would choose with a deployment pipeline. It is the approach that let the thing keep changing in the environment it actually had.