Skip to content

Route unmapped email domains to review instead of inventing agencies - #207

Open
collinschreyer-dev wants to merge 14 commits into
devfrom
feature/agency-mapping-phase-1
Open

Route unmapped email domains to review instead of inventing agencies#207
collinschreyer-dev wants to merge 14 commits into
devfrom
feature/agency-mapping-phase-1

Conversation

@collinschreyer-dev

Copy link
Copy Markdown
Collaborator

Phase 1 of the agency/domain mapping work. Two related defects in how a user's
agency is derived at login.

The passthrough

grabAgencyFromEmail resolves a domain by looking the first domain label up in
AGENCY_LOOKUP, and getConfig returns its input unchanged on a miss. So an
unrecognised domain became an agency name:

Email Agency created
@wv.gov wv
@gmail.com gmail
@mit.edu mit
@deca.mil deca
@cfpb.gov cfpb

There was no unknown state, so the corpus accumulated junk agencies that have to
be reconciled by hand later.

Unresolved domains now return a Needs Review sentinel, and the original domain
is kept on Users.unresolvedDomain so an administrator can see what needs
categorising. The migration is additive, nullable and guarded.

One subtlety worth reviewing: detection tests dictionary membership rather
than comparing the result to the input. 25 AGENCY_LOOKUP entries legitimately
map to themselves (department of agriculture, department of commerce, and
others), so a string comparison would have misclassified real agencies as
unresolved.

The Login.gov scope

The scope was openid email profile. getGovernmentEmail() already existed and
was already wired into createUser, but all_emails was never requested, so it
always received an empty array and a user whose Login.gov primary address is
personal arrived as that address and resolved to the wrong agency.

Adds all_emails. No logic change was needed. Note the Login.gov consent screen
will now list the additional claim.

Verification

All previously-resolving domains are unchanged: navy.mil, us.navy.mil,
army.mil, cms.hhs.gov, hhs.gov, gsa.gov, bis.doc.gov. The five junk
cases return Needs Review. The three translateCASAgencyName assertions in
token.test.js are unaffected, verified directly because Jest could not run
locally (jest-extended is not installed).

Needs a decision before merge

A Needs Review user matches no solicitation under the current exact-agency
visibility check, so they receive no feed until an administrator categorises
them. That is deliberate, but it is a policy change and should be confirmed with
Laura.

Not yet done: end-to-end confirmation against a real Login.gov account holding
both a personal and a .gov address.

collinschreyer-dev and others added 5 commits August 31, 2026 08:51
…cies

Two related defects in how a user's agency is derived at login.

grabAgencyFromEmail resolved a domain by looking the first domain label up in
AGENCY_LOOKUP, and getConfig returns its input unchanged on a miss. An
unrecognised domain therefore became an agency name: @wv.gov produced an
agency called "wv", @gmail.com produced "gmail", @mit.edu "mit", @deca.mil
"deca", @cfpb.gov "cfpb". There was no unknown state, so the corpus accumulated
junk agencies that later have to be reconciled by hand.

Unresolved domains now return a 'Needs Review' sentinel. Detection tests
dictionary membership rather than comparing the result to the input, because 25
AGENCY_LOOKUP entries legitimately map to themselves (for instance "department
of agriculture"), and a string comparison would misclassify those as unresolved.

The original domain is retained on Users.unresolvedDomain so an administrator
can see what needs categorising; 'Needs Review' on its own is not actionable.
The migration is additive, nullable and guarded.

Separately, the Login.gov scope was "openid email profile". getGovernmentEmail
already existed and was already wired into createUser, but all_emails was never
requested, so it always received an empty array and a user whose primary address
is personal arrived as that address. Adds all_emails to the scope; no logic
change was needed.

Verified: navy.mil, us.navy.mil, army.mil, cms.hhs.gov, hhs.gov, gsa.gov and
bis.doc.gov all resolve exactly as before; the five junk cases now return Needs
Review; and the three translateCASAgencyName assertions in token.test.js are
unchanged.

Note: Needs Review users match no solicitation under the current exact-agency
visibility check, so they receive no feed until an administrator categorises
them. That is the intended interim behaviour and needs Laura's confirmation.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… scopes

Phase 2 of the agency mapping work. Extends the existing Agencies table rather
than creating a parallel one, because Agencies and agency_alias already exist
and are populated with 629 rows.

Schema:
  Agencies.parentId           parent/component hierarchy
  Agencies.agencyType         federal_agency | federal_component | state_local |
                              education | other | needs_review, check-constrained
  Agencies.deviationSourceId  whose deviation applies; NULL inherits from parent
  Agencies.active, .provenance
  agency_domains              email domain -> agency, replaces the 7 hardcoded
                              UNIQUE_EMAIL_AGENCY_MAPPING entries
  agency_solicitation_scope   which agencies' solicitations a user may see
  Users.agencyId              added alongside Users.agency, which stays

The architectural point is that solicitation access and deviation inheritance
are separate relationships. Access lives in agency_solicitation_scope, deviation
in Agencies.deviationSourceId, and neither derives from the other. Scope is an
explicit join table rather than derived from parentId because the rules are not
uniform: a Navy user sees only Navy while a CMS user may be scoped to CMS and
HHS, and a derived model cannot express both.

Seeding takes the 165 AGENCY_LOOKUP names and 7 domain mappings already encoded
in config. It deliberately does not infer parents: the config has no reliable
parent data and guessing would produce a hierarchy that looks authoritative but
is not. Parents come from the Phase 5 spreadsheet reconciliation or from admin.

Every agency is given a self-scope row, which reproduces today's exact-match
visibility behaviour. Nothing changes for existing users until scopes are
configured.

Also removes AGENCY_HIERARCHY from config.js. 108 lines, six parent agencies,
19 offices, 21 domains, and zero references anywhere in srt-api or srt-ui. It
was an earlier unfinished attempt at this same problem, superseded by this work.

Verified against a copy of the local SRT database: both migrations apply
cleanly, producing 11 new agencies (629 pre-existing, correctly distinguished by
provenance), 7 domain mappings, 640 self-scope rows, and 10 of 11 users linked
by exact name match. Rollback restores the database exactly, with no residual
columns or tables.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…n inheritance

Replaces the exact-string agency match in getPredictions with a lookup against
agency_solicitation_scope, and adds the deviation resolver that walks the parent
hierarchy. The two are computed from different tables and neither is derived
from the other, which is the separation this phase exists to establish: a
component can inherit its parent's deviation without gaining sight of the
parent's solicitations, and can be scoped to another agency's solicitations
without changing whose deviation applies to it.

The read filter still matches both the agency and office columns, as before.
The GSA admin bypass is unchanged.

Deployment is behaviour-preserving. solicitationScopeFor falls back to
[user.agency] when the user has no agencyId, when the agency has no scope rows,
and when the database call fails, so every user currently in the system sees
exactly what they saw before. Phase 2 seeded a self-scope row for each agency,
which reproduces the old filter exactly. Failures narrow visibility rather than
widen it.

Tests cover the three unchanged-behaviour states, the new scoping capability,
inactive agency exclusion, cycle termination in the inheritance walk, and both
directions of the visibility/deviation independence property. Mutation-checked:
reintroducing parent-derived visibility fails 3 tests, removing the fallback
fails 1, and removing the depth cap hangs the cycle test.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
23 of the 24 /api/rag-analytics/* routes were registered with no middleware at
all. SRT applies authentication per route rather than through a blanket
middleware, and these were never given any, so they were reachable
unauthenticated from the internet. The handlers perform no authorization of
their own.

This exposed more than analytics reads. saveStage, deleteStage, savePipeline and
deletePipeline are unauthenticated writes to pipeline configuration, and
execute-pipeline, execute-stage, test-completion, test-embeddings,
generate-prompt and package-synthesis all invoke LLM and embedding backends,
so an anonymous caller could both alter pipeline config and spend model budget.

Guards follow what the UI already enforces on the client side. Every analytics
page is behind AdminGuardFn in app.routing.ts, so those routes get
token(), admin_only(). The two endpoints the regular home page calls,
playground/analyze and playground/package-synthesis, get token() only, since
requiring admin there would break the normal user workflow.

The cause of the gap is visible in how the calls are written. art-lookup was the
one guarded route and the one called through Angular HttpClient, which attaches
a bearer token via TokenInterceptor. Every unguarded route was called with raw
fetch(), which bypasses the interceptor. The companion srt-ui change attaches
the header explicitly at those call sites.

Verified no tests and no other services call these endpoints.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ation

Adds ten admin-only endpoints so the agency model can be managed without a
config change and a deploy. Everything these routes cover previously lived in
AGENCY_LOOKUP and UNIQUE_EMAIL_AGENCY_MAPPING in config.js.

  GET    /api/admin/agency-management       full view: hierarchy, domains,
                                            user counts, access, deviation
  POST   /api/admin/agencies                create agency or component
  PUT    /api/admin/agencies/:id            edit, deactivate, reparent
  PUT    /api/admin/agencies/:id/scope      set solicitation access
  PUT    /api/admin/agencies/:id/deviation  set deviation source
  POST   /api/admin/agency-domains          map a domain
  PUT    /api/admin/agency-domains/:id      reassign or deactivate a domain
  DELETE /api/admin/agency-domains/:id      remove a mapping
  GET    /api/admin/needs-review            unresolved domain queue
  POST   /api/admin/needs-review/resolve    resolve one domain for all its users

Access and deviation are edited through separate endpoints that touch separate
tables. Setting one cannot move the other. The management view returns both side
by side so it is visible in the UI that they differ.

Guardrails:

Cycle prevention on both parentId and deviationSourceId. Phase 3 capped the read
path at depth 10 so a loop could not hang a request; that cap is a backstop, and
this refuses the write with an actionable error instead. It also refuses an edit
when a cycle already exists upstream rather than extending it.

A component cannot sit at the top of the hierarchy, and resolveNeedsReview will
attach a domain to an existing agency or to a new component under a named
parent, but will never create a top-level agency. That stays a deliberate act
through createAgency.

An agency always retains sight of its own solicitations; scope edits cannot
remove it. Agencies are deactivated rather than deleted and there is no delete
endpoint for them. Scope replacement runs in a transaction, as does needs-review
resolution.

Also fixes a Phase 2 gap: the migration added Users.agencyId but the model never
declared it, so user.agencyId was always undefined and Phase 3 scope resolution
would have fallen back to exact name matching for every user, permanently. The
column is now declared with its association.

14 tests cover the cycle walk in both columns, including the indirect and
pre-existing-loop cases, and the top-level guardrail. Mutation-checked:
reducing the cycle check to self-reference only fails 4 of them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
collinschreyer-dev and others added 9 commits August 31, 2026 12:45
Reads Laura's mapping spreadsheet, normalises it, classifies every row, compares
against what SRT already holds, and writes a Markdown report describing exactly
what an import would change. It writes nothing. --apply is deliberately not
implemented until the report has been reviewed and the flagged rows resolved.

Findings from the 256-row source:

83 rows have the affiliation and association columns transposed, a contiguous
block where the department sits in the affiliation column and its component in
the association column. Detected structurally rather than by line number, so
re-exporting the sheet cannot silently break the correction. Imported as
written, these would have inverted ten departments into components of their own
bureaus.

7 agency names appear under more than one spelling, mostly the US prefix.
Department of Agriculture appears three ways. Left unmerged they would become
separate agencies splitting their own users and solicitations.

1 suspected typo, Defense Commisary Agency. Reported, never auto-corrected: a
name that looks misspelled may be the legal name.

1 row cannot be classified from its domain and needs a human decision.
149 rows carry no domain and are created without a domain mapping.
All 256 Agency Deviation cells are empty; the column is not written.

Reconciliation against the 629 agencies in the local SRT database: 168 already
present, 88 would be created, 104 new domain mappings, no conflicts, 438 in SRT
but absent from the sheet and left alone. The spreadsheet is not treated as
authoritative for deletion.

Separately, the pass found 62 pairs of agencies already in SRT that are the same
body under two spellings, nearly all X and U.S. X. Visibility matches on the
agency name string, so a user on one spelling cannot see work tagged with the
other. None of the pairs currently carries users or predictions, so nothing is
split today, but they are worth merging.

The comparison runs over a plain read-only pg connection rather than the
Sequelize models, because requiring server/models/index.js calls umzug.up() at
import time and would apply pending migrations as a side effect of asking for a
report. It also verifies the connected database actually has an Agencies table
before comparing, so it cannot silently reconcile against an unrelated local
database.

30 tests cover transposition in both directions, the same-name and US-prefix
cases, whitespace and zero-width normalisation, typo detection, classification,
and CSV quoting.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds --apply to the reconciliation script and runs it against the local SRT
database. The transposition reading was confirmed correct before applying.

Result on the local database: 640 agencies to 718. 78 created, 159 existing
agencies given the parent the sheet names, 162 given a real type in place of the
needs_review placeholder the Phase 2 seed left, 96 domains mapped. Every new
agency gets a self-scope row, so it behaves exactly like one that has always
been there. No deviation was written and nothing was deleted.

The import runs in one transaction and rolls back whole. It fills blanks but
never overwrites: a domain already pointing somewhere is left alone, and an
agency that already has a parent keeps it. Both cases are reported rather than
merged. One real conflict surfaced, Office of Inspector General, which SRT holds
under Interior and the sheet places under Transportation. Every department has
one, so a single undifferentiated row is genuinely ambiguous and was left as it
was.

Three defects found and fixed while rehearsing against a throwaway clone:

Skipping agencies that already existed dropped most of the hierarchy. The sheet
names a parent for 169 entities but only 65 were getting one, because the
transposed rows describe components that are already in SRT as flat rows. The
import now enriches an existing agency when the field is blank, which is the
spreadsheet's main contribution.

A single pass was not enough. A row can name a parent that only takes its place
later in the same pass, so the child read a stale value. The pass now repeats
until nothing changes, capped at five.

Matching was not deterministic. SRT holds 62 pairs of agencies that are the same
body under two spellings, so a canonical key matches more than one row, and the
lookup kept whichever row Postgres happened to return last. Consecutive imports
enriched different halves of the same pair. Ordering by id and keeping the first
occurrence fixes it, and also makes the parent spelling stable.

Verified on a clone before touching anything real: three consecutive invocations
now produce 78/0/0 created, the second and third being pure no-ops. On the real
database afterwards: 718 of 718 agencies reachable from a root so no cycles, no
self-parents, no dangling foreign keys, every agency holding a self-scope row,
and zero deviation values written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Completes the remaining backend work.

Auto-decline of personal email registrations. A self-registration from a
consumer mail provider is declined on arrival and the person is emailed to say
why and how to appeal. Off by default, and deliberately reversible: the decline
sets isRejected, which an administrator clears from the Users screen, so it
filters the approval queue rather than blocking anyone permanently. That
distinction is load-bearing. State and local staff may have no government
address available, and the Login.gov work in flight may leave a personal address
as their only way in, so a hard block could shut out exactly the users we are
otherwise trying to support. Government addresses are never declined whatever
else is configured, and individual domains or addresses can be exempted without
a deploy.

Email templates are now stored. The three SRT ships with were a hardcoded array
in the Angular component, so an administrator could edit one for a single send
but never save it or add their own. They are seeded into a table with CRUD
endpoints behind admin_only. templateKey is immutable because admin_audit_log
rows already reference it, and a built-in is deactivated rather than deleted so
the shipped set can always be restored.

Duplicate agencies are resolved through aliases rather than deletion. SRT held
65 agency records that are the same body under two spellings, nearly all X and
U.S. X. Visibility matches on the agency name string, so a user under one
spelling could not see solicitations tagged with the other.

The obvious fix is to merge the rows and rewrite the losing name wherever it
appears, which would mean rewriting tens of thousands of solicitation rows and
changing what people see if it went half right. Instead the canonical record
keeps its id, the duplicate's name is recorded as an alias, and the duplicate is
deactivated. Solicitation data is never touched.

That works because it revives agency_alias, a table created in 2021, seeded with
31 aliases, and never read by anything since. It now has a model, and
solicitationScopeFor includes an agency's aliases in the names it matches on.
Visibility only widens where an alias row exists, so an agency without one
behaves exactly as before.

Verified against a clone before applying. Two defects surfaced there: the merge
ran several updates concurrently on a single pg client, which cannot do that,
and repointing scope rows tripped the uniqueness constraint because the
surviving agency already held the pair being moved onto it. Colliding rows are
now removed before repointing, and the queries are serialised. The transaction
rolled back cleanly both times, which is how the bugs were caught without
consequence.

After the merge on the local database: 718 agency rows retained, 653 active, 65
deactivated, aliases 31 to 96, no orphaned domains or scope rows, no user or
domain pointing at a deactivated agency, and no active agency still sharing a
name with another. Re-running reports nothing to do.

81 tests. New coverage for the decline policy, including that a state .gov
address survives even if misconfigured as personal, and for alias resolution,
including that an alias lookup failure narrows visibility rather than failing
the request.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…the UI

Two adjustments so the Phase 6 backend is legible from the admin console.

The agency management view now returns each agency's alternate spellings. A
duplicate that was merged survives as an alias rather than a row, so without
this an administrator looking for an agency that has apparently disappeared has
no way to see where it went or which names it now answers to.

Auto-decline now writes 'Declined (Personal Email)' rather than a raw
auto_declined key. The admin Users screen already had that exact string in its
list of review statuses and renders reviewStatus directly, so an auto-declined
account now displays, filters, and can be changed exactly like one declined by
hand. Inventing a second vocabulary would have shown the raw key in the status
column and left it unfilterable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…e test matrix

Login resolution now consults agency_domains before falling back to the config
maps. This was plan item 3.1 and it had been missed: Phase 3 delivered
scope-aware visibility but left grabAgencyFromEmail reading only the two
hardcoded maps, so the 96 domains imported from Laura's spreadsheet sat in the
database unused and login still answered from config. The import was only half a
feature without this.

resolveAgencyForEmail queries the domain table first and falls back to the
existing function for anything it does not know, so behaviour is unchanged for
unmapped domains and a database problem degrades to today's answer rather than
failing the login. The observable difference on real data:

  cfpb.gov       config: Needs Review    table: Bureau of Consumer Financial Protection
  ams.usda.gov   config: Agricultural Marketing Service   table: same
  ftb.ca.gov     config: Needs Review    table: Needs Review

usss.dhs.gov now resolves to "Secret Service" rather than "US SECRET SERVICE",
because the duplicate merge made the former canonical. Those users keep sight of
the same solicitations through the alias, which is what the alias mechanism is
for.

Adds the Phase 6 matrix covering government email selection, .mil handling,
deterministic selection when an account holds several government addresses, and
the regression cases from the plan. Those regressions are the point of the
original change: @wv.gov, @gmail.com, @mit.edu and @deca.mil each used to become
an agency named after the first label of the domain, so a user at wv.gov joined
an agency called "wv". Each is now asserted to reach Needs Review instead.

One test in the first draft asserted that cfpb.gov resolves through the config
maps. It does not, and never did. The assertion was wrong rather than the code,
and correcting it is what surfaced the missing 3.1 work above.

102 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Adds items 6.1 through 6.6, so all twelve scenarios from the plan now map to a
named test and the matrix is auditable against the document rather than against
memory.

6.1 Navy sees Navy work only and inherits DOD's deviation.
6.2 CMS sees both CMS and HHS work, and still inherits HHS's deviation. The
    contrast with Navy is the point: the rules are not uniform, which is why
    access is stored rather than derived from the hierarchy.
6.3 FEMA sees FEMA work only and inherits DHS's deviation.
6.4 A state body sees no federal solicitations and inherits no federal
    deviation, including the realistic case of a state user with no agency
    record at all, which is where Needs Review leaves them today.
6.5 A component with its own deviation overrides the parent chain.
6.6 Both directions of independence: inheriting a deviation upward grants no
    sight of the parent, and being scoped to another agency does not change
    whose deviation applies.

Mutation-checked. Making visibility follow the parent chain fails 6 tests,
removing the isKnownAgencyKey guard that stops unmapped domains becoming agency
names fails 10, and selecting the first email regardless of its domain fails 3.

A first attempt at the middle mutation edited a string that does not exist in
the file and so changed nothing, which briefly looked like the regression tests
were not guarding anything. They are: the corrected mutation fails 10 tests.

114 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
getConfig prefers environment variables over the config file, and they always
arrive as strings. The flag was read with plain truthiness, so setting
autoDeclinePersonalEmail to "false" in order to turn the feature off would have
turned it on instead, and every personal-email registration would have been
declined by the act of trying to disable it.

Only a real boolean true or the string "true" enables it now. Covered for
"false", "0", "no", "off", "" and "False".

121 tests.
Seeds the six templates from the program's email SOP: Access Granted, Request
Access, Government Email Needed, Non-Agency Account Access Granted, Login.gov
Troubleshooting, and Which Agency. These were being sent by hand from the
SRT@gsa.gov mailbox against a written procedure, which is why the SOP exists.

Wording is the SOP's own. Two deliberate changes. The [First name] marker
becomes {{first_name}} to match the placeholder style the existing templates
use. And the Login.gov troubleshooting template had one specific person's
address hardcoded as the one for the recipient to select, so everyone who
received it was told to choose someone else's email; that is now
{{government_email}}.

Auto-decline now sends the SOP's own "Government Email Needed" wording rather
than text invented for this feature, so an automatic decline reads identically
to one sent by hand. The built-in text remains only as a fallback if the
template is removed. The recipient's first name is threaded through, and an
unresolved placeholder is stripped rather than greeting someone as
"Hello {{first_name}}".

Two things the SOP settles that were previously open questions.

Step 7 grants access to .edu and non-federal .gov addresses, and the
Non-Agency template describes exactly the upload-tool-only experience. State and
local users are in scope by the program's own documented procedure, which is
worth knowing while the Login.gov barrier for those users is unresolved.

The Login.gov Troubleshooting template states that the cause is "a problem with
the default configuration of our app", independently confirming what the
authorization request and the Login.gov screenshot suggested. That template also
becomes largely unnecessary once SRT requests the all_emails scope, since that
is the underlying fix.

123 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant