Workflow · Intake & approvals

A request queue where the approvals set the status

Every internal IT function has the same queue problem. The work arrives in whatever form the requester felt like sending, and the answer comes back in whatever form the approver felt like giving. Neither of those is a record. When somebody asks six months later why a project was turned down, or who agreed to it, there is nothing to open.

42 fields in one shared write map
7 workflow states
3 approval roles, any one blocking
Confidential client & operation

Built for the internal IT function of a contract-logistics operation, taking software and reporting requests from every department on the site.

The problem

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

What we built

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

When the tracker is down

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

The result

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.

Built with

  • Python
  • Flask
  • SQL Server
  • GitHub REST & GraphQL APIs
  • Server-rendered templates
  • SHA-256 tokenized links

On the details we left out

Client, location, system names and volumes are withheld deliberately. We're happy to go deeper on architecture and approach on a call.

Services this draws on

System Integration

Connect aging systems to modern APIs, data platforms, and SaaS tools.

See the service →

Custom Software & Web Development

Websites, internal tools, APIs, and automation for teams without a dev department.

See the service →

More projects

Platform · Multi-year build

One application that replaced a decade of spreadsheets

A warehouse ran its floor on shared spreadsheets, emailed reports, and tribal knowledge. We replaced all of it with a single internal platform — and kept it running while people worked.

Read the story →
Casework · Damage & exceptions

Exceptions that open their own case file

A damage or count discrepancy is a claim, and a claim is worth whatever evidence is attached to it. We built the casework — photo capture at the point of discovery, a case that assigns and escalates itself, and a PDF report that goes out with the pictures already in it.

Read the story →
Inventory · Consumable supply

Knowing you'll be out of stock before you are

Packaging and floor consumables were tracked on a shared spreadsheet, and the first reliable sign of a shortage was an empty shelf. We built a scanned stockroom with a background service that projects each item's stockout date against its lead time — and emails only when something new goes wrong.

Read the story →

Got a system like this?

A free assessment gives you a clear picture and a prioritized plan — no commitment.

Book a free assessment