---
title: "Matching Engine"
slug: matching-engine
section: Placement & Matching
status: stable
description: "Configurable, multi-factor scoring system for applicant-to-job-order placement matching"
last-updated: 2026-10-07
---

# Matching Engine

Configurable, multi-factor scoring system for applicant-to-job-order placement matching

## Overview

The matching engine is the core intelligence layer that evaluates applicant-to-job-order fit using weighted, multi-factor scoring rules. It is not a simple keyword matcher — it is a configurable, sponsor-grade engine that supports hard filters (must-have requirements), soft scoring (nice-to-have requirements), bilateral preference matching, bidirectional gap analysis, and program-specific weight profiles. It reads structured matching requirements directly from job orders. The Configurable Matching Engine PRD extends this engine with a versioned, governed configuration layer — matching profiles with a draft/publish/archive lifecycle — specified in Matching Profiles — Versioned Configuration Layer below.

> **Matching Is Advisory, Not Autonomous**
>
> The matching engine produces ranked recommendations with transparent scores. It does not auto-place applicants. Staff review match results and make placement decisions. The engine informs — staff decide.

## Matching Rule Types

The engine uses three categories of rules, applied in sequence during match evaluation:

### Hard Filters (Must-Have)

Hard filters are binary — pass or fail. If an applicant fails any must-have requirement for a job order, they are excluded from the match results entirely. No score is computed. Program eligibility is delegated to the Rules Engine which evaluates structured rule definitions (typed conditions, configurable parameters, compliance gates) rather than simple boolean flags.

s program — evaluated by the Rules Engine using typed conditions (field_check, threshold_range, list_membership, etc.),
Age Requirement / Applicant must meet the job order / s window — see the cross-link to the Talent Pool Endpoint Contract immediately below.,
Must-Have Requirements / Applicant must satisfy all requirements marked as must-have on the job order (e.g., lifeguard certification, first aid),
Language Minimum / Applicant must meet the job order / s available dates must overlap with the job order
> **Age Requirement ↔ Host Talent Pool parity**
>
> The same min_age / max_age shape used by the threshold_range Age Requirement above is also accepted by the host talent pool endpoint as a query-parameter filter. The engine takes the intersection of the host's filter with the job order's rule window — the host filter can only narrow, never widen. See Job Orders &rarr; Talent Pool Endpoint Contract &rarr; Age Range Filter for the parameter contract, validation envelope, and worked examples.

### Soft Scoring (Nice-to-Have)

Soft scoring rules contribute to the match score but do not exclude applicants. Each dimension produces a 0.0–1.0 score multiplied by its configured weight. Higher scores indicate better fit.

### Preference Matching (Bilateral)

Preference matching evaluates fit from both sides — does the job order want this applicant AND does the applicant want this type of position? Bilateral fit produces more durable placements.

## Scoring Dimensions

Each soft scoring dimension produces a normalized 0.0–1.0 score. The final match score is the weighted sum of all dimension scores.

| Dimension | Score Range | Scoring Method | Example |
| --- | --- | --- | --- |
| Skills | 0.0 – 1.0 | Fuzzy match with synonym expansion | "Waterfront" matches "Aquatics" at 0.85 |
| Experience | 0.0 – 1.0 | Years vs target with diminishing returns | 3 yrs / 2 yr target = 1.0 (capped) |
| Qualifications | 0.0 – 1.0 | Degree level + field relevance | BA Recreation = 0.9 for camp role |
| Job Title | 0.0 – 1.0 | Semantic similarity to job order title | "Camp Counselor" vs "Youth Leader" = 0.75 |
| Location | 0.0 – 1.0 | Preference alignment + distance | Prefers rural, job order is rural = 1.0 |
| Language | 0.0 – 1.0 | Proficiency above minimum | Advanced vs Intermediate min = 0.8 |
| Availability | 0.0 – 1.0 | Date overlap percentage | 90% overlap = 0.9 |
| Certifications | 0.0 – 1.0 | Count of bonus certs matched | 2/3 nice-to-have certs = 0.67 |
| Hobbies Fit | 0.0 – 1.0 | Fuzzy overlap between the applicant's skills/hobbies data and the job order's `cultureHobbyTags` facet, with synonym expansion | 2/4 hobbies match camp culture tags = 0.5 |
| Eligible-Window Overlap | 0.0 – 1.0 | `eligible_window_coverage` — fraction of the job order's work period covered by the verified Eligible Window (verified vacation dates ∩ DOS country window); score-zero on total miss | Window covers 9 of 12 work weeks = 0.75 |
| Reference & Experience Quality | 0.0 – 1.0 | `reference_experience_quality` — approved reference items scored by itemId addressing with a name-match fallback | 2/2 references verified, 1/2 items approved-relevant = 0.75 |
| Returner Signal | 0.0 – 1.0 | Brand-scoped prior-application count with a configurable returner bonus (same brand, completed) | Completed Summer 2024 attempt = 1.0 |

## Program-Specific Weight Profiles

Different programs value different dimensions. Weight profiles are configurable per program, allowing the matching engine to produce contextually relevant results.

| Dimension | Camp Counselor | SWT-Camp | Teach USA | Intern/Trainee |
| --- | --- | --- | --- | --- |
| Skills | 0.40 | 0.25 | 0.17 | 0.13 |
| Experience | 0.12 | 0.30 | 0.20 | 0.25 |
| Qualifications | 0.05 | 0.04 | 0.32 | 0.22 |
| Language | 0.08 | 0.08 | 0.09 | 0.09 |
| Availability | 0.08 | 0.06 | 0.05 | 0.09 |
| Location | 0.04 | 0.02 | 0.03 | 0.04 |
| Certifications | 0.03 | 0.01 | 0.01 | 0.03 |
| Job Title | 0.02 | 0.02 | 0.01 | 0.02 |
| Hobbies Fit | 0.10 | 0.05 | 0.00 | 0.00 |
| Eligible-Window Overlap | 0.00 | 0.13 | 0.00 | 0.00 |
| Reference & Experience Quality | 0.05 | 0.05 | 0.09 | 0.10 |
| Returner Signal | 0.03 | 0.03 | 0.03 | 0.03 |

The four dimensions above shipped Aug 11 2026 (see Delivered Scoring Dimensions (Aug 2026) below) and now participate as ordinary profile criteria. The Camp Counselor weight profile was rebalanced for Hobbies Fit (CC 0.10, SWT 0.05) to accommodate the new dimension. Non-applicable programs carry 0.00 — Eligible-Window Overlap only exists for SWT-Camp, and Hobbies Fit only for the two camp programs. As part of the ship, all four flagship matching profiles were audited and normalised to exactly 1.0 via a new governed idempotent seeder, correcting prior drift (rounding errors, plus removed dimensions whose weight was never redistributed).

**SWT-Camp column versus the seeded profile.** The SWT-Camp column above does not match the profile the seeders produce, and its cells sum to 1.01. Worked out from the seeders (`runAllSeedsUnlocked` in `server/seed/index.ts`: matching profiles backfill, Returner Signal, Eligible-Window, Reference & Experience Quality, Hobbies Fit, then weight-sum normalization, which is skipped when the sum is already 1.0), the seeded SWT-Camp weights are Experience 0.25976, Qualification 0.19482, Job Title 0.19482, Eligible-Window Overlap 0.13344, Hobbies Fit 0.05, Reference & Experience Quality 0.04716, and 0.03 each for Preferred Certifications, Preferred Languages, Preferred Age Range and Returner Signal, which sums to exactly 1.0. Skills, Availability and Location are not criteria in that seeded profile; they remain dimensions in the engine's catalogue above, and a staff-authored profile can add them. These are seed defaults: a program's live weights are database rows that staff can edit, and the later seeders leave a staff-authored published profile untouched.

> **Teach USA is a weight profile only — scope page for the rest**
>
> Teach USA currently participates in the engine solely as the weight-profile column above: no Teach-specific requirement field definitions, supply-side qualification facts, or host-facing consumption surface exist yet. The gap analysis and delivery scope for a functional Teach matching engine are on [Teach Matching Engine — Prototype Gap Analysis & Scope](/docs/teach-matching-scope).

> **Weights Must Sum to 1.0**
>
> Each program's weight profile must sum to exactly 1.0. The admin UI validates this constraint when saving weight changes. All four flagship profiles were audited and normalised to exactly 1.0 by a governed idempotent seeder (Aug 11 2026 capsule, #2127). Default profiles are provided but can be customized per program by authorized staff.

## Delivered Scoring Dimensions (Aug 2026)

The four dimensions below shipped Aug 11 2026 (release capsule, tasks #2123–#2127; see Backlog Tickets — New Scoring Dimensions below). Each is specified against data Globus already captures for Camp Counselor and SWT-Camp participants, and each now participates as an ordinary profile criterion: a `matching_profile_criteria` row referencing its own scoring strategy in the strategy registry, with an explicit missing-data policy (all four score 0.0 when their datum is absent — none of them hard-filters).

### Hobbies Fit

The Skills & Hobbies intake taxonomies — V4.0 for Camp Counselor and V5.0 for Camp SWT — capture exactly 4 hobbies alongside the 3–5 required skills. Hobbies Fit scores fuzzy overlap between the applicant's skills/hobbies data and the job order's `cultureHobbyTags` facet (in `requirements.matching`), using the same fuzzy-match-with-synonyms approach as the Skills dimension. Because hobbies speak to camper-facing culture fit, the dimension is weighted higher for Camp Counselor (0.10) than for SWT-Camp support roles (0.05), and 0.00 elsewhere; the Camp Counselor profile was rebalanced to accommodate it. Data source: the composite Skills & Hobbies step payload documented on [Skills & Hobbies V4.0 (Camp Counselor)](/docs/skills-hobbies-camp-counselor) and [Skills & Hobbies V5.0 (Camp SWT)](/docs/skills-hobbies-taxonomy).

### Eligible-Window Overlap (SWT-Camp)

SWT eligibility computes a verified **Eligible Window** per applicant — the intersection of the partner-verified vacation dates and the DOS country participation window (see [Summer Work Travel Eligibility](/docs/swt-eligibility) ). The `eligible_window_coverage` dimension scores the fraction of the job order's work period covered by that window, rewarding fuller-season coverage with legally solid dates. Its seeded weight on the SWT-Camp profile is about 0.13 (0.15 when the Eligible-Window seeder first adds it, then scaled down when Reference & Experience Quality and Hobbies Fit are added; staff can change it) and it scores zero on a total miss. It is deliberately distinct from the existing Availability dimension, which scores raw self-reported date overlap and is unchanged: Availability answers "do the dates line up?", Eligible-Window Overlap answers "how much of the season is backed by the verified window?". Applicants whose window is not yet computed (dates unverified) score 0.0 without being excluded. SWT-Camp only; every other program carries 0.00.

### Reference & Experience Quality

The composite work-experience and reference items each carry a stable `itemId` and a per-item review status (see [Applicant Data vs Application Data](/docs/applicant-vs-application) and the field-level review contract on the [Skills & Hobbies (Camp Counselor)](/docs/skills-hobbies-camp-counselor) page). The `reference_experience_quality` dimension scores approved reference items by itemId addressing with a name-match fallback, surfacing as a relevance-aware quality signal on all four flagship profiles. The years-vs-target Experience dimension is unchanged — quality is additive, not a replacement.

### Returner Signal

The applicant-vs-application split (see [Applicant Data vs Application Data](/docs/applicant-vs-application) ) makes returners directly queryable: one `applicants` row, many `applications` attempts. Returner Signal is a brand-scoped prior-application count: applicants who have completed a prior season with the same brand (excluding the application being scored) receive a configurable returner bonus. It is defaulted into all four flagship programs and intentionally weighted low (0.03 across all programs) so it acts as a tiebreaker for proven performers, never overriding fit.

## Matching Profiles — Versioned Configuration Layer

The Configurable Matching Engine PRD wraps the field-driven engine above in a governed configuration layer: the **matching profile**. A matching profile is the versioned unit of matching configuration for a program — it bundles the criteria (which requirement field definitions participate, at what weight, with which scoring strategy and missing-data policy) into a single artifact that moves through an explicit draft → published → archived lifecycle. This mirrors the Rules Engine's rule-version model (see Rules Engine &rarr; Publish Lifecycle) and replaces the current edit-in-place behaviour of field definitions and weight profiles.

> **Built vs planned**
>
> Everything above this section — hard filters, soft scoring, preference matching, `requirement_field_definitions`, per-program weight profiles, bidirectional matching, gap analysis, score transparency, dismissals, and the four-tab admin console — is **delivered by the P7 ticket set** (see Implementation Tickets below). The profile layer in this section is the **new configuration layer from the PRD**: it is now built (versioned `matching_profiles` and `matching_profile_criteria`, draft / published / archived lifecycle, season scope, persisted `match_evaluations`; Tasks #2058-#2064). The publish validation gate is only partly enforced; see Publishing Validation Gate & Impact Testing.

Two new tables anchor the layer:

| Table | Purpose |
| --- | --- |
| matching_profiles | Versioned profile per program (optionally per season). Carries status (draft / published / archived), version number, publish/archive audit fields, and clone lineage. |
| matching_profile_criteria | One row per criterion in a profile version: FK to requirement_field_definitions, weight, hard-filter vs soft-scoring role, scoring-strategy key, and missing-data policy. |

### Draft / Published / Archived Lifecycle

| State | Mutable? | Used for scoring? | Allowed transitions |
| --- | --- | --- | --- |
| draft | Yes — criteria, weights, strategies, missing-data policies can be revised | No | draft → published (via the publishing gate), draft → archived (discard) |
| published | No — fully immutable | Yes — selected as the active profile for its (program, season) scope | published → archived (when superseded) |
| archived | No | No — retained for audit and evaluation replay | Terminal; clone into a new draft instead |

Published profiles are immutable. Any change — even a single weight tweak — requires cloning the published version into a new draft, editing the draft, and re-publishing through the validation gate. Publishing a new version into the same (program, season) scope archives the previously published version atomically. This replaces the earlier model in which field definitions and weight profiles were edited in place; scoring now reads a program's published matching profile when one exists and falls back to the legacy in-place field definitions otherwise, and saved legacy weights can be promoted into a published profile with `POST /api/admin/programs/:id/matching-weights/publish`. A publish that fails the gate (profile publish and matching-weights publish) returns HTTP 400 `VALIDATION_ERROR` with `details.code` `MATCHING_PROFILE_GATE_FAILED` and `details.issues`, a list of objects with `code`, `message` and optional `detail` (for example `strategy_type_mismatch` or `invalid_missing_data_policy`). `MATCHING_PROFILE_PUBLISH_GATE_FAILED` is only the internal error class code and never appears in a response.

### Profile Criteria & Missing-Data Handling

Each `matching_profile_criteria` row declares, per field: its role (hard filter or soft-scoring dimension), its weight, the scoring strategy it references, and a **configurable missing-data policy** — what the engine does when the applicant or job order lacks the datum (e.g. score zero, score neutral, exclude the dimension and renormalize, or fail the hard filter). Today missing-data behaviour is implicit in each score extractor; under the profile model it is an explicit, per-criterion configuration validated at publish time.

### Season-Level Overrides

Profiles are keyed by program, with optional season-level overrides: a season profile version, when published, supersedes the program-level profile for evaluations scoped to that season. Today weights are keyed by program only — season overrides are a new capability. Resolution order is season profile → program profile; a season without its own published profile inherits the program's.

`GET /api/admin/programs/:id/matching-profiles/season-scope` (either `programs.view` or `matching.profile.view`; 404 if the program does not exist) is a read-only summary of which profile is active per season. It returns `programId`, `programProfileId` and `programProfilePublished` for the program-level fallback, and a `seasons` array with, per season, `seasonId`, `seasonName`, `activeProfileId`, `scopeType` and `hasOverride` (true when the active profile is a season-scoped override).

### Publishing Validation Gate & Impact Testing

Publish-time validation currently enforced server-side: every criterion's scoring strategy must exist in the catalogue and accept the field's data type and params (`strategy_type_mismatch`), and every criterion must carry a valid missing-data policy (`invalid_missing_data_policy`; `fail_hard_filter` only on hard-filter criteria). The remaining checks in the list below are specified but not yet enforced: the pluggable gate is a pass-through and no impact-test run is required. Specified gate:

- Every criterion references an existing, active requirement field definition.
- Soft-scoring weights sum to exactly 1.0.
- Every referenced scoring strategy exists in the strategy registry and is compatible with the field's data type.
- Criterion schemas validate against the field definitions they reference.
- Every criterion has an explicit missing-data policy.
- A **successful impact test** has been run against the candidate draft — the Test Harness becomes a required publish precondition, not just an ad-hoc preview. The gate records which impact-test run authorized the publish.

### Scoring-Strategy Registry

A registry of named, reusable scoring strategies (e.g. fuzzy-match-with-synonyms, years-vs-target-diminishing-returns, date-overlap-percentage, proficiency-above-minimum) becomes the engine's extension point, replacing per-field score extractors as the place where new scoring behaviour is added. Profiles reference strategies by key; strategies declare which field data types they accept. No hardcoded program-specific logic — a new program is onboarded purely by authoring a profile.

### Persisted Evaluation Model

Every match evaluation is persisted with its full score breakdown, tied to the exact profile version that scored it:

| Table | Purpose |
| --- | --- |
| match_evaluations | One row per (applicant, job order) evaluation: total score, hard-filter outcome, FK to the matching_profiles version used, evaluated-at timestamp. |
| match_evaluation_dimensions | One row per criterion: raw score, weight, weighted contribution, strategy key, missing-data disposition. |

This gives the score-transparency surface a durable audit history — staff can see not just today's breakdown but how an applicant scored last month and under which profile version, and recomputes are explicit, auditable events.

Optional environment variable `MATCH_SCORING_V2` (`1` or `true` to enable; unset or any other value leaves it off) switches job-order matching to the catalog-driven V2 scorer (`calculateConfidenceScoreV2`, weights from the active match_field_definitions, seeded with the legacy weights so scores are parity-identical). It only applies to an applicant for whom no published matching profile resolves for their season or program (and only when the job order has a program); a resolved profile always scores through the profile engine. If V2 scoring throws, that applicant is scored by the legacy engine instead.

### Job Order Validation Against Published Profiles

Job order matching requirements are validated against the **published profile version** for the job order's program/season, not against live field definitions. A job order authored under profile v3 remains coherent even after v4 publishes; recompute against a newer version is an explicit, RBAC-gated action that writes new evaluation rows.

### Granular Matching RBAC

The single `matching_admin` gate is decomposed into granular permissions: view profiles, create draft, edit draft, run impact tests, publish, archive, trigger recompute, and view score breakdowns. This lets program staff run tests without publish rights and keeps publish/archive with governed roles, mirroring the Rules Engine's publish-permission model.

## Bidirectional Matching

The engine supports matching in both directions, serving different operational needs:

### Demand &rarr; Supply (Find Applicants for a Job Order)

Given a job order with available capacity, find and rank all eligible applicants by match score. This is the primary view for placement staff working through job order fill operations. Matching itself is hidden from the applicant; the participant-facing milestones and communications along the placement journey are specified in [Participant Placement Journey & Notifications](/docs/participant-placement-notifications).

Input: Job Order ID with must-have requirements, nice-to-have preferences, and available capacity / Hard filters applied: applicants failing must-have requirements excluded / Soft scoring computed: each nice-to-have dimension scored and weighted / Preference matching applied: bilateral fit bonus / Output: Ranked list of applicants with per-dimension score breakdown

Staff read routes (all `job_orders.view` or `job_orders.manage`, API key scope `job_orders.read`, entity scope enforced against the program, brand and host of the job order): `GET /api/admin/job-orders/:id/matches/:matchId` returns one match (404 for an unknown job order or match); `GET /api/admin/job-orders/:id/matches/:matchId/evaluations` returns the append-only `match_evaluations` history for that job order and applicant with per-criterion dimension rows, latest first; `GET /api/admin/applicants/:id/job-order-matches` returns the matches of the applicant across job orders and silently drops matches whose job order is outside the caller scope.
### Supply &rarr; Demand (Find Job Orders for an Applicant)

Given an applicant, find and rank all job orders with available capacity by match score. This appears on the applicant profile and is used when staff are looking for placement options for a specific person.

Input: Applicant ID with profile, skills, and preferences / Hard filters applied: job orders with failed must-have requirements excluded / Soft scoring computed: each nice-to-have dimension scored and weighted / Preference matching applied: bilateral fit bonus / Output: Ranked list of job orders with per-dimension score breakdown

A related applicant-side read, `GET /api/applications/:applicationId/host-market-coverage` (session, `applicant.view`, application scope enforced; 404 for an unknown application), reports the host-market reach for the applicant's programme and whether the applicant is discoverable in the Host Talent Pool. It returns `programId`, `programName`, `activeHostCount` (distinct host organisations with an active agreement covering the programme), `activeHosts` (up to 5 hosts ordered by name, or `null` when the caller lacks `host.profile.view`), `evaluatedAt` and a `visibility` verdict (`visible`, `reasons`, `eligibleHostCount`, `eligibleHosts`). Reason values: `programme_not_in_host_market`, `market_ready_not_evaluated`, `market_ready_not_ready`, `no_eligible_host_agreement`, `active_reservation` and `active_placement`.
## Gap Analysis Engine

The gap analysis engine identifies mismatches between supply (eligible applicants) and demand (open job order positions) at the program, skill, and location level.

Job orders that have fewer qualified applicants than available positions.

Example: “Job order needs 10 lifeguards, only 2 qualified applicants in pool”

Eligible applicants with skills/qualifications that have no matching job orders.

Example: “15 waterfront-qualified applicants, only 4 lifeguard positions across job orders”

### Gap Alert Thresholds

Configurable thresholds trigger alerts and auto-publish events when gaps exceed acceptable levels:

| Threshold | Default | Action |
| --- | --- | --- |
| Demand Low (per open slot) | Fewer than 3 qualified, non-dismissed applicants per slot (`minQualifiedPerSlot`) | Publish gap.demand_low event |
| Supply Excess | Zero-match applicants exceed open slots x 2.0 (`supplyExcessRatio`); also raised when applicants exist but no slots are open | Publish gap.supply_excess event |
| Skill Category Shortage | At least one slot in the category is under the per-slot minimum of 3 | Publish gap.skill_shortage event |
| Zero-Match | Any applicant with zero eligible matches against open slots | Publish gap.zero_match event |

> **See also: Placement Fill-Rate Dashboard**
>
> The Demand Fill Rate threshold above is the same fill-rate metric staff watch on the operational landing surface. Surface spec (KPI cards, 30 s polling contract, program-manager recompute action, < 3 s render budget at 10K+ placements): Placement Fill-Rate Dashboard .

## Match Score Transparency

Every match result includes a full per-dimension score breakdown so staff can understand exactly why an applicant scored the way they did. No black-box scoring.

Example Match Breakdown

Total Score 
0.87 / 1.00 
### Dismissal Tracking

Staff can dismiss irrelevant match suggestions. Dismissed matches are excluded from future results for that job-order-applicant pair but remain accessible in the dismissal log with the reason recorded. Dismissals can be undone by authorized staff.

**Access.** `GET /api/matching/gaps`, `GET /api/matching/gaps.csv` and `POST /api/matching/gaps/run` take the `program_id` and `season_id` query parameters and need only an authenticated session or API key, with no staff permission (the gap read returns 404 when no snapshot exists yet; run returns 201). `POST /api/matching/dismissals` is equally open and requires a non-empty reason (422 `POLICY_VIOLATION` with `details.code` `dismissal_reason_required`), returns 404 for an unknown job order or applicant, and 409 `CONFLICT` when a live dismissal already exists for the pair. Only reversing a dismissal, `DELETE /api/matching/dismissals/:id`, is gated, by `matching_admin`.

## Matching Engine Admin

Authorized staff configure the matching engine through the Matching Configuration page (/matching/configuration, gated on the matching.profile.view permission) and the Program Edit matching panels. The matching_admin permission gates only reversing a dismissal (`DELETE /api/matching/dismissals/:id`); creating a dismissal and the gap routes need only an authenticated session or API key. The console is built on the generic field-driven framework delivered by P7 tickets #1, #3, #7, and #8 — there is no per-rule CRUD surface anymore. Behaviour is controlled by editing field definitions, weight profiles, and dismissal records, with a non-destructive test harness for previewing the downstream impact of any change.

> **No more rule rows**
>
> The retired model exposed a list of individual rule rows (create / edit / activate / deactivate / prioritize). That surface is gone. Hard filters, soft-scoring contributions, and preference matching are now derived from the per-program field definitions and weight profiles — the four tabs below are the only authoring surfaces.

### Fields Tab

Manages the requirement_field_definitions catalogue (P7 ticket #1). Each row defines, per program, one field that can appear on job_orders.requirements and on the corresponding applicant profile facet. Definitions are read through `GET /api/programs/:programId/requirement-field-definitions`, which needs only an authenticated session or an API key of any scope (no staff permission, deliberately, so API-key callers work). It returns the active definitions for the program, program-scoped rows over the global fallback set, and the literal id `global` returns the global set only. A field definition declares:

### Weights Tab

Manages per-program weight profiles in the matching_weight_profiles table (P7 ticket #3). Profiles are keyed by (program_id, requirement_field_id) and replace the legacy global scoring-dimension weights.

Saving weights (`PUT /api/admin/programs/:id/matching-weights`) only updates the legacy weight vector (the program's weight-profile revision history, oldest first, is listed by `GET /api/admin/programs/:id/matching-weights/revisions`; `matching.profile.view`, API key scope `admin.read`; 404 for an unknown program). `POST /api/admin/programs/:id/matching-weights/publish` promotes that saved vector into a published, program-scoped versioned matching profile: it builds a draft from the saved weights (soft-score criteria for positive-weight dimensions, the three fixed 0.03 preference criteria, `score_zero` missing-data policy, no hard filters) and publishes it through the standard publish path, so the publish-gate validations and the archive-previous-published supersede apply. The call requires `programs.manage` or `matching.profile.publish`. It is idempotent: if the currently published program-scoped profile already matches what the saved weights would produce, it returns that profile with `alreadyPublished: true` instead of minting a new version. It fails with a `no_saved_weight_profile` validation error when the program has no saved weights, a 422 `weight_profile_must_sum_to_one` when the stored vector no longer sums to 1.0, and a 422 `published_profile_has_custom_criteria` (with `customFieldKeys`) when the published profile carries hand-authored criteria the legacy mapping would discard — in that case staff must clone the published profile to a draft and publish through the Matching Profile Criteria editor instead.

The Weights panel is also backed by a unified dimension-weight API over the full scoring-strategy catalogue: `GET` / `PUT /api/admin/programs/:id/matching-dimensions` read and save the panel into an auto-managed program-scoped draft (cloning the published profile when one exists), and `POST /api/admin/programs/:id/matching-dimensions/publish` saves and publishes through the standard publish path. Soft-score dimension weights must total 1.0 (error code `dimension_weights_must_sum_to_one`); preference dimensions are additive bonuses and are excluded from the sum; hard-filter criteria are passed through untouched.

**Revision history and stored definitions.** Every accepted `PUT /api/admin/programs/:id/matching-weights` also writes one append-only row to `matching_weight_profile_revisions` (`program_id`, `actor_user_id`, the full `weights` vector as JSON, `recorded_at`) in the same transaction; `GET /api/admin/programs/:id/matching-weights/revisions` (permission `matching.profile.view`) returns them oldest first. Every accepted `PUT /api/admin/programs/:id/field-definitions` writes a row to `requirement_field_definition_revisions` (`program_id`, `actor_user_id`, the full `definitions` vector with `fieldKey`, `mode`, `weight` and `scoreExtractor`, `recorded_at`), read by `GET /api/admin/programs/:id/field-definitions/revisions` (`matching.profile.view`; latest 20, newest first). Separately, `match_field_definitions` holds the richer per-field rows behind the requirement-field lookup: `program_id` (null for the global fallback set), `field_key`, `data_type` (`enum`, `multi-enum`, `scalar`, `boolean` or `date-range`), `match_mode` (`hard_filter`, `soft_score` or `preference`), `extractor_key`, `weight`, `enum_options`, `help_text`, `required`, `active` and `version`.

**Field-definition admin routes.** Besides the `PUT` and `revisions` routes above, `GET /api/admin/programs/:id/field-definitions` (permission `matching.profile.view`) reads a program's definitions, and `POST /api/admin/programs/:id/field-definitions/preview` (permission `matching.profile.test`) runs the impact preview. `GET /api/admin/programs/:id/field-definitions/preview-job-orders` (permission `matching.profile.view`) feeds the preview's job-order picker with the program's most recent job orders in status approved, active or partially_approved (at most 20, newest first). `DELETE /api/admin/programs/:id/field-definitions/:fieldKey` (permission `matching.profile.archive`) removes one definition from the vector and returns `programId`, `fieldKey` and `deleted: true`. All of these return 404 for an unknown program.

### Dismissals Tab Operational view onto the match_dismissals table (P7 ticket #7). Dismissals are recorded as staff hide irrelevant suggestions from the matching dashboard; the admin tab surfaces and manages them centrally.

### Test Harness Tab

Non-destructive preview of how a proposed change to field definitions or weight profiles would affect live scores. Powered by ScoringEngine.scoreBatch() (P7 ticket #2) running against a chosen dataset without writing through to the live profile. Under the profile model, this harness becomes a required publish precondition — see Publishing Validation Gate & Impact Testing above.

### Matching Configuration Page

A dedicated `/matching/configuration` page surfaces the weight panel, the versioned Matching Profile Criteria editor, and the season-scope overrides outside the Program Edit page (it renders the same shared panels). It is gated on `matching.profile.view` rather than `programs.manage`. Its landing view is a brand-grouped overview from `GET /api/admin/matching/configured-programs` showing, per program, whether a weight profile is saved, the draft/published/archived version counts, and the season override count (status badge: Published, Draft, or Legacy weights only). A user who holds the permission but has no brand, program or season access grant sees a scope-empty warning instead of an empty list.

## Evaluate Endpoint — Deferred Side-Effects (2s SLA)

POST /api/admin/job-orders/:id/evaluate carries a hard 2 second p95 SLA. The endpoint runs the matching algorithm synchronously, persists results, and returns the eligible-applicant set to the caller; every per-eligible side-effect (audit row, notification, downstream webhook) is deferred so that fan-out cost does not bleed into the request budget.

Deferred Side-Effect Pattern

While iterating eligibles, the evaluator appends a DeferredMatchSideEffect[] queue in-memory (one entry per eligible). Entries are pure value objects — audit / notification — and carry no I/O. / The HTTP response is written first. A single post-response flush drains the queue exactly once, in declaration order, after res.end() has fired. / Audit and notification fan-out remains 1:1 with the eligibles list — deferral changes when the side-effects run, never how many run or whether they run. / Flush failures are isolated per entry, recorded as their own audit row, and never retroactively fail the HTTP response. The evaluate result remains the source of truth even if a downstream notification later errors. / The same queue model is reused by the dry-run preview path, but the post-response flush is a no-op there — preview never writes audits or fires notifications.
> **Why deferred, not asynchronous**
>
> The work is deferred within the same request lifecycle, not handed off to a background worker. This keeps the side-effect contract simple (no queue table, no retry scheduler, no eventual-consistency window) while still protecting the 2s SLA: the caller sees the result as soon as it is computed, and the fan-out runs immediately afterward on the same process.

## Implementation Tickets — P7 (Matching Engine & Skill Intelligence)

The matching-engine scope above is delivered by the ten tickets below, in execution order, on Asana project P7: Matching Engine & Skill Intelligence (Phase C) . The ticket set delivers a generic field-driven matching framework (field definitions, scoring engine, per-program weight profiles, bidirectional matching, gap analysis, score transparency, dismissal tracking, admin UI) plus the two Layer 7 surfaces (Placements Overview and Placement Cancel).

| # | Ticket | Scope |
| --- | --- | --- |
| 1 | Generic field-driven requirements schema on job_orders.requirements JSONB 1214895438429333 | requirement_field_definitions table; per-program data type / match-mode / score-extractor / default weight; submit-time validation; backfill of the three legacy program models. |
| 2 | Generic scoring engine (hard filters + weighted soft scoring + preference matching) 1214895997653771 | ScoringEngine.score() + scoreBatch() pure module that reads field definitions, applies hard filters, sums weighted soft-score contributions, layers preference bilateral fit, and returns a per-dimension breakdown. |
| 3 | Per-program weight profiles (configurable scoring weights per program) 1214895380404261 | matching_weight_profiles table keyed by (program_id, requirement_field_id); SUM(weight)=1.0 enforced at write time; revision history; immediate effect on the next score call. |
| 4 | Bidirectional matching (demand→supply and supply→demand) 1214910788923208 | GET /api/matching/demand-to-supply and /supply-to-demand streaming endpoints sharing ScoringEngine.scoreBatch; demand→supply excludes applicants holding overlapping confirmed placements. |
| 5 | Gap analysis and threshold-driven gap events 1214894968645937 | GapAnalysis.run() job; immutable snapshot rows; publishes gap.demand_low / gap.supply_excess / gap.skill_shortage / gap.zero_match; GET /api/matching/gaps with CSV export. |
| 6 | Per-dimension score transparency surface (match breakdown panel) 1214895998003410 | MatchBreakdownPanel component on slot detail, applicant profile, and matching dashboard; per-dimension raw / weight / contribution rows; CSV export; RBAC gated by host screen. |
| 7 | Dismissal tracking with reversal 1214895380209172 | match_dismissals table keyed by (slot_id, applicant_id) with required reason; dismissed pairs filtered from both matching endpoints; POST + DELETE endpoints; reversal gated by matching_admin; full audit. |
| 8 | Admin UI (field definitions, weight profiles, dismissals, test harness) 1214895380572264 | Originally scoped as a four-tab `/admin/matching` console (Fields, Weights, Dismissals, Test harness) gated on matching_admin; as built, staff use the Matching Configuration page at `/matching/configuration`, gated on `matching.profile.view` (see above). The weight panel requires soft-score weights to total 100%, and the Program Edit field-definitions panel offers a Preview impact action. |
| 9 | Layer 7: Placements Overview surface 1214895437974112 | /staff/placements paginated, filterable list (state, program, season, host org, brand); read-only here; server-side scope cannot be widened by crafted query parameters; hosts the cancel action added by ticket #10. |
| 10 | Layer 7: Placement Cancel + capacity release on cancellation 1214894916261981 | POST /api/placements/id/cancel with required reason; atomically transitions placement → Cancelled and releases the slot's capacity counter; full audit on the placement and the slot. |

> **Sequencing**
>
> Tickets are dependency-wired in the order listed: each ticket depends on the one above it, and the project's P7 Complete — Gate Milestone depends on ticket #10. The dev portfolio rollup on Delivery Status / Delivery Overview reflects the new ticket set automatically — no orphan stage rows remain from the retired skill-category / rule-CRUD tickets.

### Backlog Tickets — New Scoring Dimensions

The four dimensions from Delivered Scoring Dimensions (Aug 2026) above were filed as tickets in the P7 intake column and shipped Aug 11 2026 (release capsule, #2123–#2127). They now count toward the P7 delivered scope.

| Ticket | Dimension | Status |
| --- | --- | --- |
| Matching engine: Hobbies Fit soft-scoring dimension 1217036680712796 | Hobbies Fit | Shipped (Aug 11 2026, #2126) |
| Matching engine: Eligible-Window Overlap soft-scoring dimension (SWT-Camp) 1217036700195562 | Eligible-Window Overlap | Shipped (Aug 11 2026, #2125) |
| Matching engine: Reference & Experience Quality soft-scoring dimension 1217036636801664 | Reference & Experience Quality | Shipped (Aug 11 2026, #2124) |
| Matching engine: Returner Signal soft-scoring dimension 1217036765385836 | Returner Signal | Shipped (Aug 11 2026, #2123) |

For how matching integrates with the placement workflow, see P5: Matching & Placement Core . For the job order foundation, see Host Portal Governance — Job Order Lifecycle . For workforce gap analysis, see Workforce Capacity . For the live-app build spec for the People & Placements dashboard (the read-only operator surface that consumes match outputs after acceptance), see Placements Overview &rarr; Engineering Brief .
