Abhiraaj Konduru

OneSource · Xenspire Group · Summer 2026

Flow360: breaking out of the applicant tracking mental model.

Flow360 is the internal platform OneSource's recruiters use to run their work. I found the model the business was actually operating in, drew it so the team could see it, and owned the refactor that moved a live codebase onto it.

Problem
Flow360 had grown into a copy of a standard applicant tracking system (ATS): jobs with a linear pipeline. But the business doesn't work in one line. People sit in buckets, like a job, a talent pool, or a campaign, and move between them. New kinds of buckets kept appearing faster than developers could hardcode them.
What I did
Modeled the business as typed objectives, made architecture diagrams to get the team aligned, and owned the frontend and backend refactor that moved the existing system onto that model, including migrating production data without stopping the app.
Hardest decision
Where to break cleanly and where to keep legacy. I kept the existing Job Order table and its integrations, but replaced loose objective-to-job links with strict one-to-one ownership, and turned "which bucket is this person in" into an enforced record.
How I tested
Rehearsed the migration on a full production copy (100,000+ person records). It surfaced 7,117 workflow cards pointing at positions that no longer existed. I repaired them, found the deletion bug that caused them, and added foreign keys so it can't happen again.
What I learned
UX is a test of the model. If a recruiter has to create a job, then create its objective, then pick that objective again from a menu, the abstraction is leaking into the product.

The pattern I noticed

Recruiting software is usually built around one shape: a job, and a line of stages a candidate moves through until they're hired or rejected. That's an ATS. Flow360 had become one.

But when I looked at what the business actually did all day, most of it didn't fit a line. We keep a network of great people we're not placing right now. We run outreach to companies that might become clients. A person can be on two jobs at once, and when a job closes, they shouldn't just vanish. The common thread wasn't "pipelines." It was that people always belong to some bucket, and some buckets are going somewhere while others aren't.

The team already had an early idea called "objectives." The problem was that the code still treated them through the ATS model, and the business was moving faster than the code could follow. So I stepped in, spent a lot of time understanding the model, drew it out, and then worked on bridging the gap between the diagrams and the code.

Before: objectives as loose hubs

The original objectives worked like folders with sticky notes pointing at jobs. A folder could point at several jobs. A person could be in many folders with no idea which one was "current," or in none at all. A talent pool, which has no finish line, still had to pretend to be a pipeline. And recruiters saw both "Job Orders" and "Objectives" in the sidebar, which were really the same thing seen twice.

The old model: objectives are hubs linked to several jobs through a join table, a talent pool is forced into a pipeline, and people can belong to many hubs or none. RECRUITER SEES Job Orders Objectives "Working objective" picker in the header two nouns, one thing Objective: "Acme" a hub, not the job itself links to 0..N jobs Job Order 4422 Job Order 4423 Job posting join table: who owns what? Objective: "Talent pool" no finish line, but forced to have pipeline columns New Contacted Done?? PEOPLE Sam in 3 hubs, none "current" Jane in no hub at all so each feature invents a "default" Priya moved, but no record of it moves silently overwrite history Membership came from several legacy sources.
Before. One table was trying to mean "the job," "the workspace," and "the pool" all at once, which is why every new feature had to guess.

After: three kinds of objective

Here's the metaphor I used. Every person is always standing somewhere, in one of three kinds of place:

Kind
In recruiting
In marketing (where it's going)
TargetA conveyor belt. Many of them.
Job Order. People move along stations toward a hire. Every job is its own belt, so targets are instances.
sourced → screened → interview → offer
Campaign. Prospective clients move toward a signed deal. Same machinery, different belt.
prospect → contacted → meeting → client
OngoingA lounge. Exactly one.
Talent network. Great people we're staying in touch with. No stations, just a status, and outreach on a schedule. One per group, so it's a singleton.
Nurture list. Past clients and warm leads we keep in contact with over time.
TerminalThe exit. Never a trash can.
Not moving forward. Out of active work, but the person and their history are kept.
Not a fit. Same idea for companies.

The point of the abstraction is that last column. A marketing campaign and a job order look unrelated, but they're the same shape: move people along a belt toward an outcome. Build that shape once and the business can reuse it anywhere. Users never see the word "objective," though. They see a Job Order, or a Campaign, because those are the things they understand.

The new model: inside a group, target objectives are job orders with stages, the ongoing objective is one talent network, and the terminal objective is the exit. Rules move people from targets to the pool automatically, but moving someone onto a job is always a human decision. GROUP · HEALTHCARE RECRUITING a person belongs to one group at a time TARGET · INSTANCES Job Order 4422 · ICU Nurse Sourced Screened Interview Offer Job Order 4423 · Charge Nurse Sourced Screened Interview Offer ● Sam is on both jobs. Allowed: many targets, same group. The Job Order is the objective's context: one to one, never "linked." ONGOING · SINGLETON Talent network No stages. Each person has a status and gets outreach on a schedule. Exclusive. TERMINAL · SINGLETON Not moving forward Out of active work. Kept, never deleted or "nowhere." rule: job closes human decides exits Every move is a transaction with a recorded reason, actor, source and destination.
After. Rules can move people out of a job into the pool automatically, but putting someone onto a job always takes a person (or an agent with permission), because that decision carries real business meaning.

The membership rules live in PostgreSQL, not just the app, so every write path follows them: a person can be on several targets in the same group, but an ongoing or terminal objective is exclusive. Entering one closes their seats on targets in the same transaction. Each move is recorded with who did it and why, so "where is Jane?" always has an answer, and so does "how did she get there?"

Pieces, not features

The second goal was to stop needing a developer every time the business wanted a new kind of bucket. Businesses iterate faster when the people running them can change the process themselves. So the approach is: we build the pieces, and admins put them together. Workflows already have editable stage names on top of stable IDs, so renaming a stage can't break where candidates are sitting.

The next step, which I built after the main refactor and is still in development, is an admin catalog where non-developers define new objective types and their custom fields. Today it supports creating new ongoing and terminal types; arbitrary new target types are next. Native fields stay in their real columns, and custom ones go into typed value rows, so the existing Job Order queries didn't have to change.

Bridging into a live system

None of this was a rewrite. Flow360 had real data, integrations, and other engineers changing it at the same time, so the model had to be moved in underneath a running product. I worked through it in extended sessions with the team and owned the workstream between them.

Migration steps: normalize old links, give every job one owner, enforce with constraints while old code still runs, rehearse on a production copy, then repair the data and prevent recurrence. 01 Normalize split hubs that held several jobs into one objective each 02 One owner backfill each job's objective; orphans get a typed one 03 Enforce deferred constraints so old insert order still works mid-cutover 04 Rehearse full production copy, no schedulers or email workers started 05 Repair + prevent fix broken positions, fix the deletion bug, add foreign keys 104,477 person state records built 7,117 broken workflow positions repaired 3,011 stale tasks reconciled 15 / 15 API checks, no row drift
The migration, staged. Numbers are from the documented rehearsal on a full production copy. Every stage had preflight checks, backups, and post-run audits; the migration refuses to finish if any position is still unresolved.

The rehearsal was the most valuable part. Checking that the new tables existed would have passed. Checking the actual invariant, "every card points at a real stage on its template," found 7,117 that didn't. Rather than just fill in the blanks, I traced why: the app's delete check counted cards owned by objectives but missed cards owned by Job Orders, so stages were being deleted out from under people. I fixed the check and added restrictive foreign keys, so the database itself now refuses to create that kind of damage.

I also wired outreach sequences to the workflow: a sequence starts when a candidate reaches one stage and stops when they reach another, with recruiter approval when needed, and finished sequence steps stop showing up as outstanding tasks.

The code was still thinking in pipelines. The business was thinking in buckets. My job was to find the model that fit the business, then carry it all the way into the code.