---
title: "Rules Engine"
slug: rules-engine
section: Rules & Compliance
status: stable
description: "The single authority for every eligibility, compliance, and configuration-integrity decision in Globus."
last-updated: 2026-10-07
---

# Rules Engine

The single authority for every eligibility, compliance, and configuration-integrity decision in Globus. Built on a five-layer architecture (Policy, Facts, Evaluation, Enforcement, Action Orchestration), a typed rule-tree model, first-class versioning, deterministic replay, and governed override policy — all specified at final maturity before engineering begins.

> **Documentation surface — implemented in Globus, not here**
>
> Every API endpoint, table, RBAC scope, reason code, response envelope, and SQL fragment on this page is a normative contract for the Globus implementation site. Nothing on this page is implemented in this portal — this is the architecture/specification site. The canonical Source of Truth for downstream contradictions is the Normative Contracts section; older sections of this page that conflict with it are superseded.

> **One Engine, One Decision Authority**
>
> The Rules Engine replaces every scattered eligibility check — matching hard filters, SEVIS readiness probes, workflow publish gates, finance-pass checks, host vetting conditions — with one structured system that every consuming service queries. The engine produces a structured decision object that carries outcome, blocking effect, required actions, machine-readable reason codes, and case recommendations. Consumers never bypass it and never implement their own parallel logic. See how the engine gates lifecycle transitions in the Applicant Lifecycle Model .

## The Five-Layer Engine

The engine is decomposed into five layers with explicit contracts between them. Each layer owns a single concern, is independently testable, and has a single input/output contract so the boundaries cannot blur over time.

### Layer 1 — Policy

Policy owns what rules exist. A rule has an immutable identity (rule_definitions) plus one or more immutable versions (rule_versions) containing the rule tree and default parameters; action rows and the override policy are stored in the related rule_actions and rule_override_policies tables. Each action row carries its own reason code and queue-routing fields — reason codes are bound at the action level, not the version level. A policy bundle is the set of active rule versions resolved for a given (brand, program, season, application type, phase) context at a point in time. Retroactive policy changes and future-dated rules are expressed as new versions — the engine never mutates a historical version.

Contract out of this layer: a policy bundle identified by policy_bundle_version, plus the parameter values resolved from Brand → Program → Season hierarchy.

### Layer 2 — Facts

The Facts Layer produces a canonical, stable view of the world for the entity under evaluation. Upstream sources (applicant profile, documents, SEVIS, finance, matching, placement, host records) speak different schemas and change shape over time. The Facts Resolver normalizes them into canonical facts like passportExpiryDate, backgroundChecks, feeStatus, hasSevisCheckinOverdue. Every fact carries provenance metadata (collectedAt, sourceLastUpdatedAt, freshness, confidence, conflictStatus). Rules read facts — never raw service payloads — so schema drift in a source service is absorbed by the resolver, not leaked into thousands of rule expressions.

### Layer 3 — Evaluation

Evaluation is a pure function: given (policy bundle, resolved parameters, facts snapshot), it produces a decision object. No I/O, no side effects, no calls out to services. Purity is what makes deterministic replay possible: re-running evaluation with the same inputs always produces the same outputs, regardless of the current state of the world. The evaluator walks the rule tree, resolves named predicates, evaluates derived facts, applies count and time-window operators, and classifies the outcome.

### Layer 4 — Enforcement

Enforcement interprets the decision's outcome (pass / fail / review_required / indeterminate / error) and each action row's blocks_transition flag at the point of a lifecycle gate, publish check, or data mutation. It is the layer that stops a transition from happening, marks an applicant as hold-for-review, or allows passage with a surfaced warning. Enforcement never decides — it applies decisions handed to it by Evaluation.

### Layer 5 — Action Orchestration

Action Orchestration reads required_actions and case_recommendations from the decision object and dispatches them: routes queue memberships, opens compliance cases, fires notifications, writes audit records, publishes outbox events. Action intents are declarative — rules describe what should happen, the orchestrator decides how.

> **Why Separating These Matters**
>
> A monolithic engine where conditions, side effects, and blocking behavior are tangled together cannot be versioned, replayed, or reasoned about. The five-layer split means a compliance officer can load any historical evaluation, swap in a proposed new policy bundle, and see how the outcome would change — without any risk of firing real notifications or opening real cases.

## Rule Families & Classification

Rules fall into two disjoint families based on what they evaluate against, independent of the regulatory / business category axis (see Rule Category & Rule Class). Family controls which engine invocation path runs the rule.

### Entity Policy Rules

Evaluate against a live entity (applicant, host, job order, J-2 dependent) at runtime to decide whether that entity can proceed. Examples: age eligibility, passport verified, SEVIS check-in overdue, host probationary standing. These are what most people think of as “rules”.

### Configuration Integrity Rules

Evaluate against configuration artifacts (workflows, program setups, season definitions) to gate publishing. Examples: every compliance rule is covered by at least one workflow step; DS-2019 issuance step exists; payment gates wired to finance status. These run when a configuration artifact is saved or published, not on runtime entities.

> **Why separate the families**
>
> A workflow coverage rule and a passport-verification rule have nothing in common operationally: different triggers, different data sources, different consumers, different UIs. Conflating them in v1 led to ambiguous ownership. Separating them now means each family can have its own specialized tooling without cross-contamination.

### Business Rule Subclasses

Business rules — those not mandated by regulation — are further sub-classified so their intent is visible in queues, analytics, and the override governance console:

> **Capacity is Operational, Not Compliance**
>
> Capacity management and waitlisting are operational business rules, not compliance. They enforce program operating policy (how many applicants a season can hold) — not federal regulation. Treating them as compliance in v1 caused confusion over who could adjust caps and whether changes needed regulatory justification. They don’t.

## Rule Category & Rule Class

Every rule carries two required, governed classification fields: rule_category (the high-level bucket) and rule_class (the functional type). Together they let operators find and manage rules by purpose — Documentation, Identity, SLA, Lifecycle Automation, Operational Review — rather than by the broad regulatory-vs-business cut alone. Both are distinct from family (which engine path runs the rule) and from the outcome / decision classes (the evaluation result, covered under Decision Classes).

> **rule_category is the legacy classification field, renamed**
>
> The compliance-vs-business axis previously stored as classification is the same axis as rule_category — there is only one such bucket per rule. Going forward it is named rule_category with values regulatory | business; the old classification value compliance maps to regulatory. Do not model them as two separate fields.

### rule_category — High-Level Bucket

Required on every rule. Stored as compliance (displayed as Regulatory), business, or operational_gate (readiness gates):

### rule_class — Functional Type

Required on every rule. The allowed values depend on the selected rule_category — a regulatory rule may only take a regulatory class, a business rule only a business class. These are a closed enum: free-text class names are not permitted in MVP.

Regulatory classes

| rule_class | What it covers |
| --- | --- |
| eligibility | Baseline statutory eligibility to participate (e.g. minimum age, program eligibility floor). |
| identity | Verifies who the applicant is and guards against impersonation or duplication (passport validity, identity verification, duplicate check). |
| documentation | Requires a regulated document be present and valid (passport uploaded, required regulatory forms). |
| compliance_attestation | Captures a legally significant declaration or attestation (criminal-history declaration, signed certifications). |
| program_qualification | Confirms the applicant qualifies for the specific program category (current-student status, job-order fit). |
| participation_window | Enforces date or time windows mandated by regulation (academic vacation dates, program start/end window). |
| compliance_gate | Seventh regulatory class accepted by the engine (RULE_CLASSES_REGULATORY); not described further here. |

Business classes

| rule_class | What it covers |
| --- | --- |
| workflow_completion | Requires an applicant- or staff-completed workflow step before proceeding (profile completion, section sign-off). |
| applicant_action | Requires a discrete applicant action or consent (terms accepted, acknowledgement). |
| lifecycle_automation | Drives automated lifecycle transitions (auto-lapse, auto-cancel, auto-advance). |
| queue_routing | Routes work into operational queues (queue assignment, reassignment). |
| sla | Time-based reminders and escalations (reminder emails, SLA timers). |
| operational_review | Routes to a human for manual operational review or decision (manual staff review). |

> **rule_class vs business_subclass**
>
> rule_class is the functional type (what the rule does); business_subclass (operational / advisory / optimization) is the enforcement posture (how hard it bites). They are orthogonal — a business rule carries both. rule_class does not replace business_subclass.

### Builder UX — Dependent Dropdown

The Rule Builder shows a Category control (Business / Regulatory) and a Class dropdown whose options are filtered by the chosen category. Changing the category resets the class. Valid pairings read naturally — e.g. Regulatory + Documentation or Business + SLA. Only enum values are selectable; there is no free-text entry.

### Default Migration Mapping

Existing rules are back-filled with a default category and class on migration so no published rule is left unclassified:

| Existing rule | rule_category | rule_class |
| --- | --- | --- |
| Profile completion | business | workflow_completion |
| Terms accepted | business | applicant_action |
| Reminder emails | business | sla |
| Auto-lapse / auto-cancel | business | lifecycle_automation |
| Manual staff review | business | operational_review |
| Queue assignment | business | queue_routing |
| Passport uploaded | regulatory | documentation |
| Passport validity | regulatory | identity |
| Criminal declaration | regulatory | compliance_attestation |
| Identity verification | regulatory | identity |
| Duplicate check | regulatory | identity |
| Student status | regulatory | program_qualification |
| Vacation date / window | regulatory | participation_window |
| Minimum age | regulatory | eligibility |

### Where Category & Class Appear

Once classification is governed it must surface consistently everywhere a rule is authored, listed, inspected, or served:

- Creation & edit flows — both fields are captured; category and class are required.
- Rules list — Category and Class show as columns.
- Filters — the rules list and rule bundle pages can filter by Category and Class.
- Rule detail — the detail page displays Category and Class.
- Publish validation — publishing is blocked if either category or class is missing.
- Audit log — changes to category and class are captured as audit events.
- API responses — every rule payload includes rule_category and rule_class.
## Rule Definition Anatomy

A rule is split into an immutable identity (in rule_definitions) and one or more immutable versions (in rule_versions). The version carries the policy payload: the rule tree, action rows, output contract, and override policy. New requirements create a new version — the old version stays live for replay.

### Identity & Classification

| Field | Description | Example |
| --- | --- | --- |
| key | Unique immutable system reference | passport_verified |
| name | Human-readable label | Passport Verification Required |
| description | What this rule enforces and why | Valid passport verified before SEVIS pool entry |
| family | entity_policy or configuration_integrity | entity_policy |
| rule_category | regulatory or business — governed successor to the legacy classification field | regulatory |
| rule_class | Functional type; allowed values depend on rule_category (see Rule Category & Rule Class) | documentation |
| business_subclass | Optional free-form tag (e.g. finance, capacity) — not a closed enum, orthogonal to rule_class | — |
| regulatory_basis | Regulatory citation (required for regulatory-category rules) | 22 CFR 62.10 |
| current_published_version_id | FK to rule_versions — the currently-published version | rv_passport_verified_v3 |

### Scope & Bindings

Scope (rule_scopes) declares where a rule version applies: lifecycle phases, application types, entity type, brand/program filters. Bindings (rule_bindings) attach the rule to specific trigger points — phase gates, configuration artifacts, schedules, or ad-hoc evaluation entry points. A single rule can be bound to multiple attachment points without duplicating the definition.

field
### Structured Rule Fields

Alongside the recursive rule_tree, every rule definition carries structured shorthand fields (subject_key and operator_key are required columns on rule_definitions; rule_group_key is optional) that the Rule Builder uses for its single-condition fast-path. They give the engine, the Builder, and external consumers a stable, queryable summary of what this rule is checking without having to traverse the tree. When present they must be derivable from — and consistent with — the tree; the engine validates this on publish.

| Field | Purpose | Example |
| --- | --- | --- |
| subject_key | Canonical subject of the rule — usually a fact_key or derived attribute the rule asserts against. Drives Builder UI grouping, Facts Browser back-links, and per-rule analytics filtering. | passportExpiryDate |
| operator_key | Single-condition shorthand of the comparison operator. Set only when the tree is a single leaf or simple combinator; null for compound trees. Must match the leaf operator if present. | gte |
| rule_group_key | Optional grouping label that lets compliance authors cluster related rules in the Builder, dashboards, and reports (e.g. all passport-related rules). Free-form but governed. | passport_validity |

> **Tree is the Source of Truth**
>
> When subject_key, operator_key, or rule_group_key are populated they are derived from the rule tree at author time. The engine evaluates the tree, not the shorthand fields. Publish-time validation rejects any version where the shorthand contradicts the tree.

### Internal vs Applicant-Facing Messages

Every rule version carries two distinct failure messages. Confusing them leaks operational detail to applicants and starves staff of the context they need to triage. Both are required for regulatory rules; business rules may omit the applicant-facing copy when the failure is internal-only.

| Field | Audience | Tone & Content | Surfaced In |
| --- | --- | --- | --- |
| internal_failure_message | Sponsor staff, compliance officers, queue owners | Operational, may name fact keys, regulatory citations, internal queue names, override paths, and triage pointers. | Compliance Console, queue cards, case detail, evaluation record viewer |
| failure_message (applicant-facing) | Applicants and (where relevant) hosts | Plain-language, action-oriented, locale-aware. Must not leak fact keys, internal queue names, or staff workflow detail. | Applicant Portal status banners, notification emails, Host Portal blocking notices |

> **Publish-Time Lint**
>
> Publish lint forbids identical strings between the two fields and rejects applicant-facing copy that contains a known fact_key, queue name, or regulatory citation. Compliance authors must write both — the Rule Builder will not accept a publish request with either field empty for a compliance rule.

### Inline Field-Level Failures on Portal Steps

Shipped Aug 11 2026 (Aug 11 2026 release capsule — Workflows 2, PR #1786). When a compliance rule fails and that failure is associated with a specific step field, the applicant now sees the failure message directly on the relevant field inside their portal step — exactly the way a form validation error appears — rather than a single generic banner. Staff configures which rule's failure reason surfaces on which step field through the workflowFieldName mapping in the facts catalog (see Canonical Facts Catalog ).

This supersedes the prior behavior, where applicants had no field-level visibility into why their application was blocked and only received a generic "compliance issue" message. Applicants now see the specific issue and the corrective action inline, without staff needing to send a separate communication.

| Concern | Gate |
| --- | --- |
| Staff-side compliance view | compliance.console.view |
| Applicant-side inline field feedback | the rule's applicant_visible flag on the reason code |

## Publish Lifecycle

Rule versions are not edited in place — they move through an explicit draft → published → archived lifecycle. Only published versions are loaded into the active policy bundle and evaluated at runtime. Draft versions are visible in the Rule Builder and the Test Runner but are invisible to the engine’s evaluation pipeline. Archived versions remain queryable for replay and audit but are never selected for new evaluations.

### Rule Version States

| State | Mutable? | Loaded into Engine? | Allowed Transitions |
| --- | --- | --- | --- |
| | Yes — rule_tree, params, messages, scopes can be revised | No | draft → published (via publish action), draft → archived (discard) |
| | No — fully immutable | Yes — selected for active policy bundle | published → archived (when superseded by a newer published version of the same rule_definition) |
| | No | No — but queryable for deterministic replay | Terminal state. Cannot return to draft or published; clone into a new draft instead. |

Each rule_versions row carries: status (draft | published | archived), version_number (monotonic per rule_definition), cloned_from_rule_version_id (nullable FK; set when authoring a draft from an existing version), published_at / published_by_user_id, and archived_at / archived_by_user_id.

**Definition delete and rule-set membership.** `DELETE /api/rules-engine/definitions/:id` (`compliance.rules.govern`, body `reason` of 8 to 2000 characters) permanently removes a rule definition with its versions, test cases, scopes and bindings, after detaching it from sets and bundles. It returns `404` for an unknown id and `409` with top-level `error.code` `RULE_HAS_OVERRIDES` while applicant overrides still reference the rule (revoke them first; the same code is returned if an override appears between the check and the delete). It returns `409` with code `RULE_DELETE_BUSY` and `details.retryable` set to true when the database reports error code `55P03`; the message says the rule is in use by another operation and no deletion was committed. Bulk add and bulk remove on `/api/rules-engine/business-sets/:id/rules/bulk-add` and `/bulk-remove` (`compliance.rules.author`) are refused wholesale for a published set with `409 RULE_SET_PUBLISHED_IMMUTABLE` and for an archived set with `409 RULE_SET_ARCHIVED`; a deprecated set still accepts changes.

**Complete draft creation.** `POST /api/rules-engine/drafts` (`compliance.rules.author` and `compliance.rules.assign`; the route declares API-key scopes `compliance.read` and `admin.write`) validates the body against `ruleDraftCreationSchema`, which carries `definition`, `version`, `scopes` and `actions`, and creates the draft through `createCompleteRuleDraft`. Before creating, it checks the caller's `assign` authority for the definition's classification. It checks each scope (an unknown programme is a not-found error, and a season that does not belong to the selected programme is a validation error) and each action (the action type must be applicable to its outcome class, and the action passes the fail-action queue check). The audit entry (`create` on `rule_definition`) is written with `writeAuditInTransaction` from a callback that receives the transaction. The route returns `201` with the created result.

### One Published Version Per Scope

At most one version of a given rule_definition may be in the published state for a given (brand, program, season) scope window at any moment. Drafts can stack freely; only one is live at a time. Publishing a new version of the same rule into the same (brand, program, season) scope automatically archives the previously-published version in the same transaction and emits both publish-audit entries atomically. This is the contract the runtime resolver depends on (see Runtime Resolution).

> **Effective-Dating vs Publishing**
>
> effective_from / effective_to control when a published version is considered active inside the engine. Publishing is the act of making a version eligible for selection; effective dating selects which eligible version applies at a given evaluation timestamp. A version may be published yet not-yet-effective.

### Publish Audit Trail

Every state transition writes one row to rule_version_publish_audit. The table is append-only and immutable; rows are never updated or deleted. Together with the version row itself it gives compliance a full reconstruction of who changed what, when, and why.

| Field | Purpose |
| --- | --- |
| rule_version_id | FK to the version that transitioned |
| rule_definition_id | Denormalized FK for fast filtering by rule |
| from_status / to_status | Both states recorded — draft→published, published→archived, draft→archived |
| changed_by_user_id | User who performed the transition (must hold publish permission for the rule classification) |
| actor_role | Role at the time of action — captured for replay even if the user's role later changes |
| reason | Required free-text justification on every transition; sponsor audits expect this present |
| diff_summary | JSON summary of what changed vs the prior version (rule_tree hash, parameter deltas, scope deltas, message deltas) |
| test_cases_passed / test_cases_total | Retired: columns remain for historical rows but are no longer written (test cases no longer gate publish) |
| policy_bundle_version_after | The new active policy bundle version stamped after this publish; null for archive |
| ip_address / user_agent | Request metadata for security forensics |
| changed_at | Server-side timestamp of the transition (immutable) |

### Publish Gates (Builder Contract)

The Rule Builder (Task #165) is the only legitimate path to publish. It must enforce all of the following before the publish API will accept the request — and the API itself re-checks each gate server-side:

**Outcome copy gate (`OUTCOME_COPY`).** Every not-passed (`fail` / `needs_review`) action on a rule version must carry authored outcome copy: severity, reason code, destination queue, an applicant-facing message (the action's own `applicantMessage`, separate from the version-level applicant failure message above), and a remediation that names its `kind` plus an `applicantInstruction` or `operatorGuidance` (the next action and its owner). A `pass` outcome is never judged. The gate reports `INCOMPLETE_OUTCOME_COPY` naming the missing fields per action. It runs after the reason-code and queue gates and suppresses only a field those gates already blocked for the same action. A small capped register of exceptions (parameter-carrier rules such as `reservation.hold_duration_bound.*`, and unbound dormant `j1.intern|trainee|intern_trainee.*` placeholders) is exempt. The same definition of "complete" is re-checked at boot by a published-rule outcome-copy audit: blocking outside production, logged (not fatal) in production, where the deploy gate in `scripts/run-migrations-psql.sh` blocks instead.

**Gate result codes.** Each gate returns issues, which block publish, or warnings, which are reported without blocking. Every entry carries `gate`, `code`, `message` and optional `detail`; clients should switch on `code`. A missing version or definition returns a single issue and no other gate runs.

| Gate | Code | Result | When |
| --- | --- | --- | --- |
| `REQUIRED_FIELDS` | `VERSION_NOT_FOUND`, `DEFINITION_NOT_FOUND` | Issue | The rule version, or the definition it belongs to, does not exist. |
| `REQUIRED_FIELDS` | `MISSING_REQUIRED_FIELD` | Issue | A required field is empty or `unspecified`; `detail.field` names it. Checked: `internal_failure_message`, the version's `applicant_failure_message` and `fail_safe_mode`, plus `subject_key` and `operator_key` (not required for operational gates). |
| `REQUIRED_FIELDS` | `IDENTICAL_FAILURE_MESSAGES` | Issue | The definition's internal failure message and the version's applicant failure message are identical. |
| `REQUIRED_FIELDS` | `MISSING_RULE_CLASS`, `INVALID_RULE_CLASS_FOR_CATEGORY` | Issue | `rule_class` is empty, or is not valid for the definition's classification. |
| `TREE_VALIDITY` | `TREE_INVALID`, `TREE_HAS_NO_LEAVES` | Issue | The rule tree fails schema validation, or contains no leaf predicate. |
| `TREE_VALIDITY` | `UNKNOWN_FACT_KEY` | Issue | A leaf, count or time-window fact key, or a `$fact` operand, is not in the fact catalog (`__now__` is always allowed). |
| `TREE_VALIDITY` | `PARAMETER_REF_MALFORMED`, `FACT_REF_MALFORMED` | Issue | A leaf comparison value uses `$param` or `$fact` without naming a key. |
| `TREE_VALIDITY` | `UNRESOLVABLE_PARAMETER_REF` | Issue | The tree compares against a parameter that has no value on the version, either in its parameter bag or in a `rule_parameter_defaults` row. |
| `TREE_VALIDITY` | `SHORTHAND_GROUP_KEY_INCONSISTENT` | Issue | `rule_group_key` is set but a leaf subject falls outside that group prefix. |
| `GATE_DEPENDENCIES` | `NO_GATE_DEPENDENCIES`, `DUPLICATE_GATE_DEPENDENCY`, `GATE_DEPENDENCY_SELF_REFERENCE`, `DEPENDENCY_NOT_FOUND`, `INELIGIBLE_GATE_DEPENDENCY`, `DEPENDENCY_NOT_PUBLISHED` | Issue | Operational gates only, in place of the tree check: no prerequisite, a repeated or self-referencing one, one that does not exist, one that is not a regulatory or business rule, or one with no current published version. |
| `SCOPE_PRESENT` | `NO_SCOPES` | Issue | No `rule_scopes` row on the version. |
| `ACTION_PRESENT` | `NO_FAIL_ACTION` | Issue | No action with outcome class `fail`. |
| `REASON_CODE` | `MISSING_REASON_CODE`, `REASON_CODE_NOT_FOUND`, `REASON_CODE_ARCHIVED`, `REASON_CODE_DEPRECATED` | Issue | Checked per `BLOCK`, `WARNING`, `REVIEW_QUEUE`, `REQUIRE_DOCUMENT` and `AUTO_REJECT` action: no reason code, an id that does not resolve, or an archived or deprecated code. |
| `VISIBILITY_POLICY` | `INVALID_MODE`, `INCOMPLETE_POLICY`, `INVALID_PREDECESSOR`, `FORWARD_PREDECESSOR` | Issue | Operational gates only, and only when a visibility policy is explicitly authored on the version: the mode is not Always or Progressive, a Progressive policy has no earliest journey phase, or the predecessor gate family is unknown or is not an earlier family. |
| `OUTCOME_COPY` | `INCOMPLETE_OUTCOME_COPY` | Issue | Described in the outcome copy gate paragraph above. |
| `QUEUE_ASSIGNMENT` | `MISSING_QUEUE_ASSIGNMENT` | Issue | A `REVIEW_QUEUE` action has no queue key. |
| `REASON_CODE_QUEUE_MISMATCH` | `REASON_CODE_QUEUE_MISMATCH` | Issue | The reason code's routing area is not among the areas allowed for the destination queue. Passes when the queue is unconstrained or the code has no area. |
| `SCOPE_CONFLICT` | `WILL_SUPERSEDE_PUBLISHED_VERSIONS` | Warning | An immediate publish will supersede prior published versions covering the same scope. |
| `SCOPE_CONFLICT` | `OVERLAPPING_PUBLISHED_VERSIONS` | Issue | The same overlap on a future-dated version; archive the conflicting versions first. |
| `REMEDIATION_TARGET` | `REMEDIATION_TARGET_MALFORMED`, `REMEDIATION_TARGET_UNRESOLVED` | Issue | A remediation target (`portal_step`, `profile_field` or `document_type`) is missing its key, field name or document type code or id, or names a portal step, promotable profile field or document type that does not exist. |
| `REMEDIATION_TARGET` | `REMEDIATION_FACT_KEYS_NO_OVERLAP` | Warning | Declared remediation fact keys do not overlap the facts the rule evaluates. |
| `AUTHORITY` | `MISSING_AUTHORITY_CONTEXT`, `INSUFFICIENT_AUTHORITY` | Issue | The publishing actor role was not supplied, or is not one permitted to publish rule versions. |
| `PUBLISH_REASON` | `MISSING_PUBLISH_REASON`, `PUBLISH_REASON_TOO_SHORT` | Issue | Compliance rules only: no publish reason, or one shorter than 8 characters. Business rules need no reason. |
| `LINT` | `APPLICANT_COPY_MENTIONS_FACT_KEY`, `APPLICANT_COPY_MENTIONS_QUEUE_CODE`, `APPLICANT_COPY_MENTIONS_CITATION` | Issue | The applicant failure message names `fact_key` identifiers, an internal queue code such as `queue:compliance_review`, or a regulatory citation (22 CFR, INA, USCIS, 8 U.S.C.). |
| `BUNDLE_ASSIGNMENT` | `MISSING_BUNDLE_ASSIGNMENT` | Warning | A compliance or business rule is not yet in a regulatory bundle or business rule set, so it has no runtime effect until assigned. |

**Reviewed candidate publication.** A candidate-preview route exists at `/api/rules-engine/versions/:id/candidate-preview`. The publish request body of `POST /api/rules-engine/versions/:id/publish` has a `candidatePreview` object with `inputHash` (64 lowercase hex characters) and `evaluatedAt` (an ISO datetime with offset). For compliance rules, business rules and operational gate versions that have a progression binding, the publish route checks whether `candidatePreview` is present. When it is missing, the route throws a conflict error with the message `Review the saved candidate and its impact before publishing.` and the code `CANDIDATE_PREVIEW_REQUIRED`.

### Imported Drafts & Environment Sync Publication Preflight

Shipped Sep 2026. Two adjacent guarantees make machine-generated rule and bundle drafts safe to activate.

**Imported-rule drafts publish only after review, all-or-nothing.** A file-based configuration import never modifies a published rule or regulatory bundle; it lands **fresh** rule-version and bundle draft rows and records their exact ids on the import run (the run's landed set is a provenance boundary — an older draft elsewhere in the Rules Library is never pulled into the batch). Data Management then offers **Review & publish drafts**:

| Purpose | Method | Path |
| --- | --- | --- |
| Preview imported drafts | GET | `/api/admin/config-import/runs/:runId/publish-preview` |
| Publish imported drafts | POST | `/api/admin/config-import/runs/:runId/publish` (body: `publishReason` 8–500 chars, `previewToken`) |

- The preview returns rule and bundle counts, live publish blockers (the same gates as the Builder Contract above), superseded versions, bundle reach, queue-routing consequences with the governing SLA policy (a destination without an active policy is a warning, not a blocker), and a `previewToken` fingerprint of what was reviewed.
- Publication locks and reloads the complete recorded set, re-derives the impact inside its own transaction and compares it with the approved review. Rules publish before bundles so every membership resolves to a live rule. Any rule, queue-route or bundle validation failure rolls back the **entire** batch; the batch is never trimmed to the members that would pass.
- `400` — a recorded draft is no longer a draft (published or archived by someone else), or the reason/token is missing; nothing was published. `409 STALE_PUBLISH_PREVIEW` — something consequential moved between review and confirmation (new supersession, a gate now failing, a routing destination deactivated, bundle reach widened); reopen the preview and confirm the current one. `409 IMPORT_RUN_NOT_OPERATOR_OWNED` — the run belongs to Environment Sync and must be activated through Environment Sync's own finalisation, never through this route.
- **Reason-code rule on rule-definition import.** When a configuration import applies rule definitions, a `BLOCK` or `REVIEW_QUEUE` action with no `reasonCode` fails the import with an `ImportError` (it was previously accepted silently with a null reason code), so queue routing and reporting always have a reason code to key off. A `reasonCode` that does not exist in the target environment also fails, with a message to seed the reason-code vocabulary first.

**Environment Sync Dry Run catches publication blockers before Apply.** The Development → QA Environment Sync plan fingerprints QA's covered configuration and runs the *real* import pipeline inside a rollback-only transaction. Every automatic rule-publication candidate is then pushed through the same action and publication gates that Apply would use; the transaction is always rolled back and every blocker is returned in one plan without changing QA. Operators review field-level differences, validation errors, staged drafts, operational-gate drafts and proposed QA-only retirements, and continue only when the publication preflight reports no validation errors for any candidate. A failed preflight names the rule and the gate; correct the source rule or QA dependency and create a fresh plan — Apply is not the tool for discovering the next error.

**Incomplete Development baseline.** When the Development rule and bundle manifest is incomplete (for example `ruleDefinitions` or `ruleBundles` absent, an entry without an explicit `lifecycle`, a rule with an unsupported `classification` outside compliance, business, regulatory and operational_gate, or a published or draft rule without an explicit behavior-bearing version), the plan from `POST /api/admin/environment-sync/development-to-qa` (system admin) returns 200 with a `blocker` object whose `code` is `CONVERGENCE_MANIFEST_INCOMPLETE` and whose message lists every problem, and `POST /api/admin/environment-sync/finalize` returns 422 with the same top-level `error.code`. Fix Development and create a fresh plan; the gap is not filled from QA. See [Config Import / Export](/docs/config-import-export) for the sync surfaces themselves.

## Test Cases

Test cases are first-class artifacts of the rules engine — not ad-hoc scripts living outside the platform. Every rule definition can carry an unbounded set of named test cases, each pinning a FactsSnapshot fixture to an expected outcome. Test cases are the mechanism by which the publish gate verifies that a candidate version still satisfies its intended behavior, and the historical record of what behavior the rule was ever asserted to have.

### Test Case Model

Stored in rule_test_cases. Cases are bound to a rule_definition, not a specific version, so they survive publishing.

| Field | Purpose |
| --- | --- |
| rule_definition_id | The rule under test (cases survive across versions of that rule) |
| name | Human-readable label (e.g. 'Passport expires day after program end — should fail') |
| description | What real-world condition this case represents |
| category | happy_path \| edge_case \| regression \| boundary \| adversarial — drives Test Runner grouping |
| synthetic_facts_snapshot | JSONB FactsSnapshot fixture: full synthetic inputs the rule will be evaluated against (mirrors evaluation_pipeline input) |
| resolved_parameters | Optional override of resolved Brand→Program→Season parameters; defaults to the version's defaults |
| expected_outcome | pass \| fail \| needs_review — the outcome the case asserts |
| expected_action_types | Array of action types the case asserts the rule will emit |
| created_by / created_at / updated_at | Author of the case (only owner or compliance admin can deactivate) plus standard timestamps |

### Test Case Runs & Version Binding

Every execution of a test case writes one row to rule_test_case_runs. Runs are immutable. They are produced by ad-hoc executions in the Test Runner UI. Test cases do not gate publishing: a rule can be published with no test cases or with failing ones; runs are informational only.

| Field | Purpose |
| --- | --- |
| test_case_id | FK to the rule_test_cases row that was executed |
| rule_version_id | FK to the specific version evaluated — every run is pinned to a version, so 'this test passed' stays meaningful after the rule is edited |
| run_by_user_id | Null for system-triggered runs |
| run_at | Server-side timestamp |
| actual_outcome | Outcome the engine produced for the case |
| actual_action_types | Array of action types the engine actually emitted |
| passed | Boolean — whether the run met the case's expectation |
| diff_summary | JSONB describing the delta when passed = false |

`POST /api/rules-engine/versions/:id/test-runs` records one run row for a version (permission `compliance.rules.author`, plus the program-manager scope guard on the test case's rule). Body: `testCaseId` (UUID), `actualOutcome`, `actualActionTypes` (default empty), `passed` and optional `diffSummary`; `run_by_user_id` is the caller. The server stores what the client reports and does not re-evaluate. It returns `201` with the run row and `404` when the test case does not exist. `POST /api/rules-engine/versions/:id/test-runs/preview` (also `compliance.rules.author`) evaluates an ad-hoc payload (`name`, `facts`, `expectedOutcome`, optional `expectedActionTypes`) against the version in memory and writes no test case or run rows.

> **Snapshotting at the Publish Gate**
>
> The publish gate executes every active test case for the rule against the candidate version, writes the run rows, and aggregates pass/fail counts onto the publish-audit row (test_cases_passed_count / test_cases_total). Any failure aborts the publish atomically — no version state change, no audit row, no policy bundle bump.

### Test Runner UI Surface

The Test Runner is a Compliance Console surface, sibling to the Rule Builder. It is the only UI through which authors create, edit, execute, and review rule test cases.

## Per-Rule Evaluation Results

*Status: the field table below is the original design. The built per-node child table is rule_evaluation_node_results, and the table named rule_evaluation_results is a separate application-scoped table (keyed by application_id and compliance_snapshot_id), not a child of rule_evaluation_records.*

rule_evaluation_records captures one row per rule version evaluated against an entity for a given trigger source and idempotency key (unique index on rule_version_id, entity_id, trigger_source, idempotency_key). Its child table rule_evaluation_node_results holds one row per tree node (record_id, node_path, node_kind, outcome, subject_key, operator_key, fact_keys).

This is the table that powers per-rule explainability: queue cards link to the specific failing rule_evaluation_results row, the case viewer shows the contributing rule’s tree-trace, and analytics dashboards aggregate “rules most often failing” without scanning JSONB blobs in the parent record.

| Field | Purpose |
| --- | --- |
| evaluation_record_id | FK to the parent rule_evaluation_records row (the bundle pass) |
| rule_definition_id | Denormalized FK for fast per-rule queries |
| rule_version_id | Exact version evaluated; pins replay |
| rule_key | Denormalized rule_definitions.key — stable identifier surfaced in queue cards, dashboards, and audit answer-back without joining |
| result | Enum: passed \| failed \| skipped \| overridden \| errored. The single field that tells you what this rule did on this evaluation pass. |
| severity | Severity at decision time (info \| warning \| blocking \| critical) — propagated from reason codes / output_contract |
| reason_codes | JSONB ordered list of reason codes this specific rule emitted |
| applicant_facing_message | failure_message after parameter interpolation; null on result = passed or when not applicable |
| internal_message | internal_failure_message after parameter interpolation; null on result = passed |
| parameters_snapshot | JSONB capture of the resolved parameter values this rule actually used (post Brand→Program→Season cascade) — required for replay without re-resolution |
| provenance | JSONB: source_scope: 'brand' \| 'program' \| 'season', inherited_from_rule_version_id: int \| null, suppressed_higher_scopes: int[] . Explains which scope's published version actually fired and what it overrode. |
| evaluated_at | Server timestamp; same as parent record's evaluated_at |

> **Why a Separate Table**
>
> The parent rule_evaluation_records stores the full decision JSONB as the authoritative replay payload, but JSONB is opaque to indexes. Per-rule rows let queues, dashboards, and Compliance Console queries filter and aggregate by (rule_definition_id, result, evaluated_at) or by rule_key with normal B-tree indexes. The JSONB blob remains canonical; the normalized table is the queryable explainability layer.

## Resolution Snapshots

*Status: the description and field table below are the original design. The built rule_resolution_snapshots table is written per evaluation, not per publish: it stores entity_type, entity_id, resolved_at, facts_schema_version, the verbatim facts snapshot and overrides_in_force, and is referenced by rule_evaluation_records.resolution_snapshot_id. It has no brand, program or season columns.*

A resolution snapshot is a system-generated, publish-time artifact that records the effective resolution of all rules for a given (brand, program, season) scope at the moment a publish completes. For every rule in the resolved set the snapshot records which published rule_version won, which scope it came from, which higher-scope versions it replaced or suppressed, and the override_reason attached to the lower-scope record. The snapshot is the canonical answer to “why did this rule come out the way it did for program P, season S?” — without forcing anyone to re-walk Brand → Program → Season.

A new snapshot row is written each time a publish completes anywhere in the (brand, program, season) scope chain. Snapshots are stored in rule_resolution_snapshots and are immutable. Subsequent runtime evaluations carry the latest snapshot’s id forward on each evaluation record so audit can trivially join evaluation → snapshot → exact rule_version per scope.

> **Never Hand-Maintained**
>
> Resolution snapshots are derived/read-model artifacts — never user-authored or user-edited. There is no UI to create, modify, or delete a snapshot. Compliance staff who need to change resolution behavior do so by editing brand defaults, program rule configs, season rule overrides, or rule versions and publishing — the publish action computes a fresh snapshot. Manual writes to the snapshot table are rejected at the storage layer.

| Field | Purpose |
| --- | --- |
| id | Snapshot primary key; referenced by every rule_evaluation_records row produced under this snapshot |
| brand_id / program_id / season_id | Scope this snapshot resolves for. season_id may be null for brand+program-level snapshots; program_id may be null for brand-only |
| policy_bundle_version | Bundle version active at the time of the publish that triggered this snapshot |
| triggered_by_rule_version_id | FK to the rule_version whose publish caused this snapshot to be computed |
| triggered_by_publish_audit_id | FK to the rule_version_publish_audit row for that publish — links snapshot to the audit story |
| resolved_rules | JSONB array; one entry per rule in the resolved set: rule_key, published_rule_version_id, source_scope ('brand' \| 'program' \| 'season'), inherited_from_rule_version_id, replaced_rule_version_ids[], suppressed_rule_version_ids[], override_reason |
| suppressed_rule_definition_ids | int[] of rules whose lower-scope override disabled them at this scope (audit trail for 'why is this rule not running') |
| computed_at | Server timestamp of the publish that produced the snapshot; immutable |
| engine_version | Engine semver that produced the snapshot |

Each rule_evaluation_records row references the active snapshot via resolution_snapshot_id, so replay can reconstruct the exact resolved policy — including which lower-scope rule version replaced or suppressed which higher-scope version — without re-walking Brand → Program → Season.

## The Rule Tree Model

Real sponsor policy cannot be expressed as a flat list of conditions. Consent requirements cascade from age. Missed-check-in violations count consecutive occurrences. J-2 cascades depend on primary J-1 status. The rule tree is a recursive, typed structure that expresses arbitrary sponsor policy while remaining statically checkable and visually editable.

### Tree Node Shape

// Every node has a discriminating "kind" field. Shared fields: id, label (for debugging).
type RuleTreeNode =
| all
| any
| not
| if_then_else
| leaf
| derived
| count / eq / gte / lte / gt / lt
| time_window / within / outside / before / after
| cross_entity (named relation, resolved by an injected resolver);
Parameter references (ParamRef) point at configurable parameter keys that are resolved from the Brand → Program → Season hierarchy at evaluation time. This keeps rule versions immutable while still letting operators tune thresholds per cohort.

### all / any / not

The boolean combinators compose sub-trees. all requires every child to pass; any requires at least one; not inverts a single child.

// Example: Fully compliant applicant = passport + background check + finance clear
all
### if / then / else

if_then_else is the only branching operator — it lets policy express conditional requirements without collapsing them into awkward any/all trees. The else branch is optional; when absent, the node passes trivially when the condition is false.

// Worked example: Under-18 applicants require guardian consent on file
if_then_else
### Leaf Predicates

A leaf reads a single canonical fact by fact_key, applies a typed operator, and compares to a literal or parameter reference. Supported operators: eq, neq, lt, lte, gt, gte, in, not_in, matches (regex), plus exists / not_exists (and truthy / falsy) for presence checks; equals, nin, in_set and not_in_set are accepted aliases. A leaf whose fact has conflictStatus = conflicting yields review_required, and a missing fact yields indeterminate, not a silent pass.

### Derived (Computed) Facts

Derived facts are computed on the fly from other facts — e.g. “days since last check-in”, “age at program start”, “J-2 surname matches J-1 primary”. They keep rule trees readable and avoid duplicating arithmetic inline. A derived fact declared in one rule can be referenced from another via its derived_fact_key.

// Worked example: J-2 surname mismatch with primary J-1 applicant
derived / j2.surname_matches_primary
// Then in the rule tree:
if_then_else
### Named Reusable Predicates

*Status: not built. The rule tree has no predicate node or named_predicate definition (node kinds are leaf, all / any / not, if_then_else, count, time_window, derived and cross_entity), and the predicate keys below do not exist in code. Host standing and fee status are facts (hostStatus, feeStatus).*

A predicate is a named, versioned tree fragment that accepts arguments. Predicates are defined once and referenced from many rules: background_check.current, host.in_good_standing, documents.wave_complete(wave: number), finance.status_is(status: string). The Rule Builder UI auto-completes predicate keys.

// Predicate definition (stored once, referenced many times):
named_predicate "background_check.current" 
// Referenced from rule tree:
predicate / background_check.current
### Cross-Entity Aggregation

Some rules need to reason about related entities: a J-2’s primary J-1, a host’s recent placements, a season’s siblings. The cross_entity node shifts the evaluation context to a related entity and evaluates a child tree against it. The count node aggregates across a collection.

// Worked example: J-2 can advance only if primary J-1 has DS-2019 issued
cross_entity / primary_of
// Worked example: probationary host + stale site visit blocks job-order approval
all
### Count Operators

count aggregates events or entities over a query, with an optional consecutive flag for streak-style rules.

// Worked example: two consecutive missed monthly check-ins → escalation
count / gte
### Time-Window Operators

time_window evaluates a fact across a window — rolling days, an absolute range, or a range defined relative to another fact (e.g. “within 90 days of program start”).

// Worked example: passport expiry date checked against the program end date (passport-validity rule)
time_window / passportExpiryDate / any
### Worked Examples — Full Rule Trees

Five end-to-end rule trees covering the full expressive range of the model.

## Canonical Facts Layer

Rules never read raw service data. Every input passes through the Facts Resolver, which normalizes source records into a small, stable vocabulary of canonical facts. This isolates the engine from upstream schema drift, gives every fact full provenance, and makes deterministic replay possible — historical evaluations replay against the facts snapshot that was persisted with the evaluation record.

The fact_key vocabulary on this page is the same vocabulary referenced by rule_evidence_bindings.fact_key and by the workflow step library’s produces_fact_keys. That shared vocabulary is what lets Workflow Studio prove, before publish, that every regulatory rule has a workflow step (or named system process) producing the facts it consumes — see the Coverage Binding Model for the publish-gate contract.

### Facts Resolver

The Resolver accepts (entity_type, entity_id, as_of_timestamp) and returns a FactsSnapshot versioned by facts_schema_version. It pulls from applicant profile, documents, SEVIS, finance, matching, placements, host records, and workflow progress — applies normalization, unit conversion, and canonicalization — then stamps each fact with provenance metadata. The snapshot is persisted alongside the evaluation record, so replay is deterministic even if source systems have since moved on.

### Canonical Facts Catalog

*Status: the table below is the original dotted-key design. The implemented catalog (the factsCatalog definitions files, 206 facts) uses camelCase fact keys such as applicantAge, passportExpiryDate, hasSevisCheckinOverdue, feeStatus, hostStatus and daysSinceLastSiteVisit, plus a few dotted keys (host.status, host.agreements.hasActiveInWindow). Rows whose key has no counterpart there (for example finance.exceptions.open_count, sevis.record_status, ds2019.issued, documents.guardian_consent.verified and the host.document.* facts) are not implemented.*

Every catalog entry also carries its presentation vocabulary: a required business `label`, a business meaning for each permitted value of an enum fact (`permittedValues`), and optional provenance (`producedByProcess`, `derivedFromFactKeys`, `computedFromFactKeys`) that drives the read-only "how this is determined" statement. Both the Rules Library and the applicant Compliance tab read fact names, value meanings and derivation statements from this one catalog. A module-load guard throws when a fact has no label or an enum fact's values have no meanings, so a missing label fails at import instead of degrading a screen.

*Illustrative rows: the `background_check.*` rows below (and the worked examples built on `background_check.current` and `background_check.completed_at`) and the `host.terms_and_conditions.*` rows are design illustrations, not code keys. The code facts catalog (`shared/factsCatalog.definitions.*.ts`) has no such entries: background-check state is carried by `backgroundCheckPackageVerified`, `backgroundCheckPackageStatus`, `backgroundCheckProcessingStatus`, `backgroundCheckReviewStatus` and `backgroundCheckTranslationRequired` (none of them is a date), and host agreement state by the `host.agreements.*` facts such as `host.agreements.hasActiveInWindow` and `host.agreements.activeInWindowCount` (none of them is an expiry date).*

| Fact Key | Type | Source | Meaning |
| --- | --- | --- | --- |
| applicantAge | number | Applicant Profile + Season | Age in years at program start_date |
| applicant.nationality | string | Applicant Profile | ISO-3166 country code |
| applicant.application_type | enum | Application | j1 \| j2 \| program_extension \| program_renewal |
| passport.verified | bool | Document Store | Passport doc uploaded and verified |
| passportExpiryDate | date | Document Store | Expiry of verified passport |
| background_check.completed | bool | Document Store | Background check on file |
| background_check.valid_on(date) | bool (parameterized) | Document Store | Background check within recency window relative to date |
| documents.wave_complete(n) | bool (parameterized) | Document Store | Wave n documents all submitted and verified |
| documents.guardian_consent.verified | bool | Document Store | Notarized guardian consent on file |
| finance.sevis_fee_cleared | bool | Finance | SEVIS fee received and reconciled |
| finance.ds2019_fee_cleared | bool | Finance | DS-2019 issuance fee cleared |
| finance.exceptions.open_count | number | Finance | Count of unresolved finance exceptions |
| placement.confirmed | bool | Placement Records | Active confirmed placement with host job order |
| sevis.checkin_overdue_days | number | SEVIS | Days past due for next required check-in; 0 if on-time or future |
| sevis.record_status | enum | SEVIS | initial \| active \| validated \| completed \| terminated |
| ds2019.issued | bool | SEVIS | DS-2019 generated and delivered |
| host.status | enum | Host Records | pending_verification \| active \| probation \| suspended \| terminated |
| daysSinceLastSiteVisit | number | Host Records | Days since most recent completed site visit |
| host.document.valid(category) | bool (parameterized) | Document Store | Derived: host document of category present AND reviewer-approved AND not expired at evaluation time |
| host.document.expires_on(category) | date (parameterized) | Document Store | Expiration date of the host document of category; null when no dated document is on file |
| host.document.policy_info(category) | object (parameterized) | Document Store | Policy information recorded on the document — issuer, policy number, coverage details, or accrediting body per category |
| host.terms_and_conditions.accepted | bool | Agreement Records | Host has an accepted, in-window operating Terms and Conditions |
| host.terms_and_conditions.expires_on | date | Agreement Records | End of the accepted Terms and Conditions validity window |
| applicant.primary_j1 | entity_ref | Applications | Cross-entity pointer from J-2 to primary J-1 applicant |
| missedMonthlyCheckinCount | integer (count) | Compliance Service | Longest run of consecutive missed monthly check-ins across the applicant's in-scope applications |

Each catalog entry may also carry a workflowFieldName mapping — the portal step field a rule's failure reason surfaces on, which drives the inline field-level failures described in Internal vs Applicant-Facing Messages .

> **Facts Catalog Gap Closure**
>
> Shipped Aug 11 2026 (Aug 11 2026 release capsule — Rules_Steps, PR #1814). Fact keys that were expected by active rule trees but absent from the catalog previously caused those rules to evaluate as permanently indeterminate. The missing keys were added during V2 UAT, and the eligibility rules engine now handles the corrected fact shapes, so the affected rule trees resolve to real pass/fail outcomes.

### Provenance Metadata

Every resolved fact is wrapped in a FactWithProvenance :

type FactWithProvenance
### Freshness, Confidence, Conflict

## Decision Classes & Output Contract

The binary pass / fail / warn model from v1 is replaced with five explicit outcome classes. Mixing “the data is missing” with “the applicant failed” hides real compliance risk; the new classes force the distinction.

### Five Outcome Classes

### Fail-Safe Modes

Each rule version declares how the engine should behave when it can’t produce a decisive pass or fail — i.e. indeterminate or error. This is the sponsor-grade replacement for “just fail open and hope someone catches it”.

> **Missing Evidence Is Awaiting, Not Invalid**
>
> A fail_closed rule rewrites an indeterminate evaluation into a hard fail so SEVIS and queue gating stay conservative. The engine still distinguishes a fail-safe rewrite caused by evidence that has not arrived from a genuine violation: when the raw outcome is indeterminate and any node reports one of the missing-data reasons — missing_fact, no_related_entity, cross_entity_resolver_missing, fact_not_array_or_missing or missing_date_fact (emitted by leaf, cross_entity, count and time_window nodes) — the check is flagged awaitingData. The Compliance tab and the compliance-case classifier (awaiting_information vs confirmed_failure) share this one reason set, and applicant readiness subtracts awaiting-submission fails from its failing count, so data that was simply never submitted does not mark an application blocked. Only genuine failures, reviewer-rejected steps and non-pass interview outcomes do.

t block, don
### Engine Output Contract

Evaluation returns a structured RuleDecision object. This is the single contract all consumers code against:

type RuleDecision ;
> **Decision is a Value, Not a Side Effect**
>
> The engine produces a decision object. It never directly fires notifications, opens cases, or updates queues. Those effects are owned by Layer 5 (Action Orchestration), which reads required_actions and case_recommendations from the decision. This separation is what makes simulation and replay safe.

> **reason_codes on the decision is a roll-up, not the source of truth**
>
> The version/decision-level reason_codes array on RuleDecision is aggregated from the reason codes carried by the actions that actually fired during evaluation. It exists for convenience (filtering, analytics, banner copy) — it is not the authoritative carrier of a single reason code. The authoritative binding is rule_actions.reason_code_id per action. If the action set fires zero actions, the roll-up is empty even when the rule itself failed; consumers that need per-cause detail read the emitted rule_outcome records, not the roll-up.

### Rule Outcome Emission

When an action fires, the engine produces a structured rule_outcome — one per fired action. This is the unit that Layer 5 (Action Orchestration) consumes. Every field is copied from the action row that fired, so the outcome is self-describing and auditable without re-resolving the rule version:

type RuleOutcome ;
> **Action fields flow straight into the outcome**
>
> action_type, reason_code_id, queue_key, severity, blocks_transition, internal_message, and applicant_message all originate on the rule_actions row. The outcome is a snapshot of that row plus the evaluation identity (applicant_id, rule_version_id, rule_action_id), so replaying a historical decision reproduces the exact outcomes without consulting the live action configuration.

### Queue Item Emission & Dedup

When a rule_outcome carries a non-null queue_key, Action Orchestration deterministically creates or updates the applicant’s queue item. Queue-item behavior is idempotent and auditable — the same logical cause never produces a duplicate row:

s policy. The engine writes the resolution transition; it never silently drops the row.,
Manual override / Staff closing an item by hand must supply a written reason. The override writes an audit record (actor, item, reason, before/after) and leaves the underlying rule outcome unchanged — only this applicant
> **Deterministic queue items, by construction**
>
> Because the dedup identity is the action that fired (not a free-form string or a timestamp), the engine guarantees one open item per (applicant, action, reason). This is what lets the Queue System render “why is this applicant here” from a stable back-pointer, and lets re-evaluation converge instead of accumulating noise.

## Versioning & Deterministic Replay

Sponsor oversight demands the ability to answer: “Why did this applicant pass on 2026-03-15? Which rule version was active? What facts were present? Would today’s policy reach the same conclusion?” That requires first-class versioning and pure, deterministic replay.

### Versioned Evaluation Fields

| Field | Purpose |
| --- | --- |
| rule_version | Exact rule_versions.id at evaluation time — immutable |
| policy_bundle_version | Resolved set of active rule versions for the (brand, program, season, app_type, phase) context |
| facts_schema_version | Schema version of the FactsSnapshot — required for replay compatibility |
| evaluation_engine_version | Engine code version — lets historical replay match historical engine behavior |
| trigger_source | What invoked the evaluation: phase_gate / data_mutation / scheduled / manual / simulation |
| trigger_operation | Human-readable action name, e.g. 'applicant.advance_phase(3→4)' |
| idempotency_key | Stable key across retries — prevents duplicate case/queue/notification fan-out |

### Policy Bundles & Future-Dated Rules

A policy bundle is a snapshot of active rule versions resolved for an evaluation context at a timestamp. Bundles are computed on demand and memoized. Future-dated rule versions sit in rule_versions with a future effective_from; they enter the bundle automatically when the clock crosses the effective date. Retroactive rule changes create a new version with an earlier effective_from, and any entity whose historical decision would change is flagged for re-evaluation — the engine does not silently rewrite history.

### Deterministic Replay Walkthrough

Load the historical rule_evaluation_records row by id / Fetch the persisted FactsSnapshot via facts_snapshot_ref / Resolve the exact rule_version and policy_bundle_version that were active / Load the evaluation_engine_version image — or simulate under current engine / Re-run evaluation as a pure function: (policy, facts) → decision / Compare the replayed decision to the persisted one — they must match byte-for-byte if engine version is held constant / In simulation mode, replay under a proposed new policy bundle and diff the outcomes

`GET /api/rules-engine/records/:id` returns one stored evaluation record (`compliance.rules.author`; `404` when the id is unknown) as `record`, with its resolution `snapshot`, the per-node `nodeResults`, the version's `actions`, the version's `reasonCode` (or null) and `canSeeFacts`. The snapshot's raw `facts` are returned as null unless the caller holds `sensitive_data.view`; an unmasked read is written to the audit log as `rules_engine.record.facts_unmasked`.
## Re-Evaluation Strategy

Re-evaluation is governed, not ambient. The engine re-fires for specific triggers, with controlled sync/async behavior, partial or full bundle scope, and explicit handling for retroactive policy changes on in-flight applicants. Compliance status is not gated on the scheduled sweep: a portal step submit that carries compliance-relevant facts triggers immediate in-band re-evaluation, and the nightly sweep remains only as a reconciliation backstop.

| Trigger | Timing | Scope | Behavior |
| --- | --- | --- | --- |
| Phase transition attempt | Sync | Full bundle for target phase gate | Blocks transition if any bound rule does not pass |
| Data mutation on a fact's source record | Async (outbox) | Partial — only rules with that fact in evidence_bindings | Re-evaluates affected rules; updates queue memberships and cases |
| Portal step submit carrying compliance-relevant facts | Async (outbox), in-band | Partial — rules bound to the submitted facts (passport data, biographical fields, skills data, etc.) | Fires immediately on submit rather than waiting for the reconciliation sweep; queued via the outbox so it does not block the submit response, but runs within seconds. Shipped Aug 11 2026 (Aug 11 2026 release capsule — Workflows 2, PR #1786). |
| Scheduled job (nightly) | Async | Full bundle for all in-flight entities | Reconciliation backstop — catches expiry-based and time-window rules (e.g. background-check staleness) and reconciles anything missed in-band |
| Lifecycle reconcile sweep (every 30 minutes) | Async | All applications | Separate from rule evaluation: re-runs lifecycle reconciliation and phase-completion catch-up so persisted journey and track stage converges to live signals; interval `LIFECYCLE_RECONCILE_SWEEP_INTERVAL_MS` (default 30 minutes), disabled with `DISABLE_LIFECYCLE_RECONCILE_SWEEPER=1` |
| Manual re-run by staff | Sync | Selectable per rule or full bundle | Writes evaluation record marked trigger_source = manual |
| Policy bundle activation | Async | Affected rules only, targeted entities | Computes impact set, runs preview first, then writes new evaluations; retroactive changes flag existing passes that now fail |
| Simulation | Sync | Targeted cohort | Pure-function replay under proposed bundle — no actions fired |

Lifecycle reconciliation follows every successful outbox evaluation of an application. After the worker has processed an application event it runs lifecycle reconciliation (trigger `rule_evaluation_outbox`) before marking the outbox event complete, so track state is never left stale behind fresh decisions. A reconciliation error, or a reconciliation that returns a skipped result, fails the attempt and the event is retried with backoff until its attempts are exhausted (then dead-lettered); the one exception is `Application not found`, which is terminal success because there is no lifecycle row left to converge.

> **Retroactive Policy on In-Flight Applicants**
>
> When a retroactive rule change would flip a passed applicant to failing, the engine does not immediately block the applicant’s next transition. Instead it opens a Compliance Case, notifies the compliance officer who activated the change, and leaves the applicant in their current phase pending case resolution. This prevents surprise blocks on participants already mid-program.

> **Immediate Re-Evaluation on Step Submit**
>
> An applicant who uploads a new passport or corrects a date-of-birth sees their compliance status update within one refresh rather than waiting for the nightly sweep. Staff no longer need to manually trigger a re-check after an applicant corrects a blocking field. Shipped Aug 11 2026 (Aug 11 2026 release capsule — Workflows 2, PR #1786).

## Reason Code Catalog

Reason codes are the machine-readable vocabulary that flows from rule decisions into queues, notifications, analytics dashboards, and API consumers. A stable catalog means downstream systems can be built against codes, not against English prose that shifts between releases.

> **Reason codes are action-level**
>
> A reason code is carried by an individual rule_actions row (rule_actions.reason_code_id) — it is the authoritative cause for that action. The version/decision-level reason_codes on RuleDecision is a roll-up aggregated from the codes of the actions that fired, never the source of truth. Every code carries an explicit lifecycle status (active / deprecated / archived) so the per-action picker can filter to active codes only and the publish gate can hard-block on archived or deprecated selections.

### Reason Code Shape

type ReasonCode ;
### Initial Reason Code Seed

*Status: illustrative design seed, not the shipped list. The shipped catalog (`server/seed/reasonCodeCatalog.ts`) seeds roughly 90 codes and most names below differ (for example `MINIMUM_AGE_NOT_MET`, `PASSPORT_VALIDITY_INSUFFICIENT`, `PLACEMENT_PREREQUISITES_NOT_MET`, `HOST_AGREEMENT_REQUIRED`, `FINANCES_NOT_VERIFIED`); only `SEVIS_CHECKIN_OVERDUE` appears as listed. Treat the rows below as design intent.*

| Code | Category | Severity | Meaning |
| --- | --- | --- | --- |
| AGE_INELIGIBLE | eligibility | | Applicant age outside program's min/max at program start |
| NATIONALITY_NOT_ALLOWED | eligibility | | Applicant nationality not in program's approved list |
| GUARDIAN_CONSENT_MISSING | documents | | Under-18 applicant missing notarized guardian consent |
| PASSPORT_NOT_VERIFIED | documents | | Passport doc not on file or not yet verified |
| PASSPORT_EXPIRES_BEFORE_PROGRAM_END | documents | | Passport expires before program end_date + required buffer |
| BACKGROUND_CHECK_EXPIRED | documents | | Background check older than recency window |
| WAVE_DOCUMENTS_INCOMPLETE | documents | | Required Wave-N documents not all submitted/verified |
| SEVIS_FEE_NOT_CLEARED | finance | | SEVIS fee not reconciled in Finance |
| DS2019_FEE_NOT_CLEARED | finance | | DS-2019 issuance fee not reconciled |
| FINANCE_EXCEPTION_OPEN | finance | | Unresolved finance exception blocks issuance |
| SEVIS_CHECKIN_OVERDUE | sevis | | Participant past required check-in window |
| SEVIS_CHECKIN_MISSED_STREAK | sevis | | Consecutive missed check-ins exceed threshold — opens case |
| DS2019_NOT_ISSUED | sevis | | DS-2019 not yet issued |
| PLACEMENT_NOT_CONFIRMED | placement | | No active confirmed placement for the applicant |
| J2_PRIMARY_NOT_APPROVED | eligibility | | J-2 cannot advance: primary J-1 not yet approved |
| J2_PRIMARY_DS2019_MISSING | eligibility | | J-2 DS-2019 blocked: primary J-1 DS-2019 not issued |
| J2_SURNAME_MISMATCH | eligibility | | J-2 surname does not match primary J-1 — requires review |
| HOST_PROBATIONARY_STALE_SITE_VISIT | host | | Host probationary with site-visit past max age — job orders blocked |
| HOST_SUSPENDED | host | | Host suspended — job orders blocked |
| HOST_DOC_BUSINESS_LICENSE_INVALID | host | | Camp host verification: Business License missing, not approved, or expired |
| HOST_DOC_WORKERS_COMP_INVALID | host | | Camp host verification: Workers Compensation missing, not approved, or expired |
| HOST_DOC_CAMP_ACCREDITATION_INVALID | host | | Camp host verification: Camp Accreditation missing, not approved, or expired |
| HOST_TERMS_NOT_ACCEPTED | host | | Camp host verification: accepted Terms and Conditions not on file or out of window |
| WORKFLOW_COMPLIANCE_COVERAGE_GAP | config | | Configuration-integrity: compliance rule has no covering workflow step |
| FACT_STALE | eligibility | | Decision is indeterminate — required fact is past its freshness SLA |
| FACT_CONFLICTED | eligibility | | Multiple sources disagree — route to review |

### Catalog Governance

## Override Governance

Override policy is declared per rule version — not by blanket role policy — so each rule can carry its own answer to “can this ever be waived, and under what conditions?”. Overrides are risk-classified, scoped, optionally time-bounded, and subject to scheduled re-review.

### Per-Rule Override Policy Classes

### Override Request & Approval Flow

Every override record captures the full context so audits can reconstruct intent:

| Field | Purpose |
| --- | --- |
| reason_code | From governed catalog — enumerates why the override was needed |
| justification | Required free-text narrative explaining the exception |
| risk_classification | low / medium / high / critical — drives alerting and review cadence |
| scope | rule (this evaluation only) / entity (all future firings for this entity) / phase / season |
| standing_flag | one_time = applies to the current decision only; standing = blankets future re-evaluations until withdrawn or expired |
| expires_at | Optional expiry; required for temporary_only override policies |
| primary_approver | User who authorized the override (must hold override permission for the rule) |
| secondary_approver | Required for dual_approval policy; must be a different user |
| before_state / after_state | Full decision snapshot before and after override — enables replay and audit |
| created_at / review_due_at | Created timestamp and next scheduled review date for standing overrides |

### Standing Override Review Cycle

Standing overrides are not fire-and-forget. The engine schedules a review at review_due_at — default 90 days, configurable per rule. On the review date the Override Governance console presents the override to a compliance officer who must renew, revoke, or escalate it. Unreviewed standing overrides expire automatically and the underlying rule re-blocks the entity.

### Non-Overridable Rules

Rules marked forbidden cannot be overridden through any path — not by super admin, not by dual approval. The API rejects override attempts with HTTP 400 (`VALIDATION_ERROR`, with the violated `policyClass` in the error details), the UI hides the override affordance entirely, and the compliance dashboard surfaces any attempt as a security event. This is reserved for absolute federal requirements (e.g. no DS-2019 without cleared SEVIS fee).

Exception for applicant compliance/regulatory rules: when the override targets an applicant entity and the rule version is the CURRENT published version of a compliance- or regulatory-classified definition, the rule is always overridable. A missing or legacy 'forbidden' rule_override_policies row is treated as 'permitted' (the published-rule audit surfaces this as a configuration finding), and the contextual Compliance action is offered whenever the version is the definition's current published version. The forbidden policy is enforced only for other rule targets and for non-current versions. Rules whose policy is dual_approval, whose definition has escalation_required, or whose policy has requires_dual_approval are routed to dual approval (pending_secondary) instead of applying on a single approval.

### Override Summary on the Compliance Tab

Shipped Aug 11 2026 (Aug 11 2026 release capsule — Rules_Steps, PR #1814). Staff can now see a live summary of an applicant's active and pending rule overrides directly on the applicant compliance tab without navigating to the Override Governance Console . The card lists, per override:

- Status — Awaiting 1st Approval / Awaiting 2nd Approval / Override Active / Under Review
- The rule the override applies to
- The justification
- The risk classification
- The expiry date

A direct link opens the full Governance console for management actions. Gate: compliance.console.view.

## Compliance Case Model

Severe compliance failures open a formal Compliance Case — not merely a queue item. A case has an owner, SLA, evidence attachments, and a resolution workflow. Cases are the durable record of how sponsor exceptions were handled; they survive individual queue lifecycles and aggregate into sponsor reporting.

### Case vs Queue Item

| Attribute | Queue Item | Compliance Case |
| --- | --- | --- |
| Opened when | Any routable failure or review_required outcome | reason_code has opens_case = true OR severity = critical OR risk_classification = high\|critical OR an override request is created (any risk; see the callout below) |
| Ownership | Queue-level routing rules (round robin, team) | Single named owner, reassignable with audit trail |
| SLA | Queue-level SLA policy (queue_sla_policies) | Case-level SLA with pause rules, separate from any queue SLA |
| Evidence | References the evaluation record | Attach docs, notes, external correspondence, sub-evaluations over time |
| Resolution workflow | Action → exit queue | Open → Investigating → Awaiting-Info → Ready-for-Decision → Resolved/Escalated — with state transitions and required fields per transition |
| Aggregation | Counted in queue dashboards | Aggregated into sponsor compliance reporting (opens, median time to resolve, outcomes) |
| Survival | Closes when queue item exits | Persists across queue churn; multiple queue items can reference one case |

### Case Fields & Ownership

type ComplianceCase ;
### Case Workflow

open → case just opened, unowned cases auto-assigned via routing / investigating → owner acknowledges; can log evidence and notes; SLA ticks / awaiting_info → owner paused case pending external input (applicant response, partner); SLA pauses / ready_for_decision → all evidence gathered; case goes to decision queue / resolved (with outcome) → override-granted / remedied / upheld / withdrawn; final evaluation recorded / escalated → handed up to supervisor or legal; continues independently from originating trigger

### Criminal-background adjudication

The compliance case whose ruleKey is compliance.criminal_background_review_outcome.j1_all_programs.p11_4 is decided by a human reviewer through the decision dialog, which offers four outcomes: Approved, Ineligible, More information required and Escalated. Recording a decision requires the compliance.review.manage permission and a rationale of at least 20 characters. The decision is persisted on the case summary as an audited criminalBackgroundAdjudication object (outcome, rationale, decidedAt, decidedByUserId, evidenceRefs) and is the ONLY source of the enum fact criminalBackgroundReviewOutcome (facts-v4.6.0). Only approved and ineligible are surfaced to rule evaluation; generic case closure, package status, translation status or an AI result can never make the rule pass.

- Approved: case resolved (resolved_compliant). Terminal.
- Ineligible: case resolved (confirmed_block). Terminal.
- More information required: case moves to awaiting_info (resolved_no_finding recorded as the resolution); ready_for_decision → awaiting_info is a legal transition.
- Escalated: case moves to escalated (escalated_external).

For More information required and Escalated the linked review-queue item is reopened rather than resolved; for Approved and Ineligible it is resolved. Every persisted decision enqueues a compliance.case_adjudication data-mutation re-evaluation of the criminal-background rule. Mismatched outcome/resolution pairs are refused (CRIMINAL_BACKGROUND_OUTCOME_MISMATCH), as are a missing or short rationale (CRIMINAL_BACKGROUND_RATIONALE_REQUIRED) and a resolve/escalate without an adjudication outcome (CRIMINAL_BACKGROUND_OUTCOME_REQUIRED).
> **When Does a Case Open vs Queue Item Only**
>
> A queue item alone is sufficient for routine failures where a single action resolves the issue (“upload passport”). A Compliance Case is opened when the reason code is flagged opens_case or the severity is critical. In addition, an override REQUEST of any risk always opens a linked Compliance Case at creation (caseKind override_request, 72-hour SLA, created with the override request; an immediately-activating override opens none). Override-request cases are decided only through the override approve/reject action: generic resolve/escalate is refused with OVERRIDE_DECISION_REQUIRED. Approval resolves the case 'overridden'; rejection resolves it 'confirmed_block'; a dual-approval override keeps the case open until the second, distinct approver approves. Review vs override: an Awaiting review result is a human decision on evidence, not an override. The Override Register records only the DB-level link to the case and defers to this page for behavior. Cases are for durable, evidence-backed, owner-accountable work — not routine checklist items.

**Case API and decision refusals.** Cases are read and decided through `/api/compliance-cases`. `GET /api/compliance-cases/:id` requires `compliance.console.view` (API-key scope `compliance.read` or `admin.read`). It returns the case, its events and rule items, `reasonSummaries` (the catalog message paired with each opened reason code), the work item, the evidence held (with actor and document names), the documents on the subject that can still be cited, and a `decision` block (`confirmedFailure`, `rationaleMinLength` and the outcomes the case may currently be closed with), computed by the same function that enforces the gate. `POST /api/compliance-cases/:id/transition` (body `toStatus`; `resolutionOutcome`, `resolutionNotes`, `pauseReason` and `criminalBackgroundOutcome` optional) and `POST /api/compliance-cases/:id/evidence` require `compliance.review.manage`, not the rule-authoring permission, and are audited as `compliance_case.decision_recorded` or `compliance_case.transitioned`, and `compliance_case.evidence_attached`. The three refusal codes return HTTP 422.

| Code | When |
| --- | --- |
| `COMPLIANCE_CASE_TRANSITION_ILLEGAL` | `toStatus` is not reachable from the case's current status. |
| `COMPLIANCE_CASE_EVIDENCE_INVALID` | Evidence cannot be attached. `details.code` is `EVIDENCE_DOCUMENT_NOT_ON_SUBJECT` when the cited `documentId` is not on the record of the subject the case is about, or `EVIDENCE_REFERENCE_UNSAFE` when `referenceUrl` is not a complete http or https address. |
| `COMPLIANCE_CASE_DECISION_REFUSED` | A resolving or escalating transition fails the decision policy. `details.refusals` lists every unmet requirement and `details.code` carries the first (codes below). |

| Decision refusal | When |
| --- | --- |
| `RESOLUTION_OUTCOME_REQUIRED` | No outcome was chosen. |
| `RESOLUTION_OUTCOME_UNKNOWN` | The outcome is not one a reviewer may record. |
| `RESOLUTION_RATIONALE_REQUIRED` | The rationale (`resolutionNotes`) is shorter than 20 characters. |
| `RESOLUTION_OUTCOME_NOT_APPLICABLE` | `resolved_no_finding` on a case opened by a confirmed rule failure. |
| `RESOLUTION_OUTCOME_STATUS_MISMATCH` | The outcome does not match the target status: `escalated_external` is recorded only with `escalated`, every other outcome only with `resolved`. |
| `RESOLUTION_EVIDENCE_REQUIRED` | `resolved_compliant` without at least one verifiable evidence item (a `documentId` on the subject's record or a safe absolute http or https `referenceUrl`), or on a review-queue item that has no case to hold evidence. |

## Rule Hierarchy & Flattening

> **Program Rules story lives on its own page**
>
> For the linear, top-to-bottom explanation of compliance vs business rules, the Brand → Program → Season cascade, compliance_locked behavior, and who edits what (with worked examples), see Program Rules — One-Page Read . The section below covers the engine-internal mechanics of resolving the cascade.

Rules are configured at three levels, resolved top-down at evaluation time. The engine flattens Brand &rarr; Program &rarr; Season into one effective parameter set per evaluation context. The rule tree itself is owned by rule_versions; only parameter values are tiered.

table
> **Compliance Rules Override All Levels**
>
> Compliance rules sit above all three hierarchy levels. They cannot be toggled or weakened at the program or season level by non-compliance users. When a compliance rule is activated, its parameters are locked across all affected program and season configurations.

## Application Type Coverage

The rules engine gates all application types, not just initial J-1 enrollment. No application of any type can be published to the applicant portal without passing the applicable compliance rules.

### J-1 Primary Applications

Full lifecycle from intake through completion. The standard rule set covers age eligibility, nationality, document verification, placement confirmation, SEVIS readiness, payment gates, and on-program compliance.

### J-2 Dependent Applications

### Program Extension Rules

Program Extension refers to extending a participant's authorized program duration on an active DS-2019. This is distinct from Reservation Extension (a partner extending their time-limited hold on a job position — see Partner Governance).

### Program Renewal Rules

Program Renewal refers to a returning participant re-applying for a new program season/cycle, creating a new DS-2019. Distinct from Program Extension (extending the current DS-2019).

## Evaluation Pipeline

Every evaluation follows the same pipeline, whether triggered by a phase gate, a data mutation, a scheduled job, or a manual re-run. Each execution produces an immutable rule_evaluation_records row plus a persisted FactsSnapshot.

> **Pipeline Guarantees**
>
> Every pipeline execution is atomic per entity — partial evaluations are never persisted. If a step fails (resolver exception, unknown fact), the prior outcome is retained, an error outcome is recorded, and fail_safe_mode applies. The evaluation record captures: rule_version, policy_bundle_version, facts_snapshot_ref, annotated tree, reason codes, required actions, trigger source, idempotency key.

### Runtime Resolution (Brand → Program → Season)

Step 1 of the pipeline (“Resolve Policy”) is governed by the Brand → Program → Season precedence cascade. Lower scopes win — a rule defined at the brand level can be replaced or suppressed by a more specific rule of the same rule_definition.key at the program or season scope. The cascade is resolved at evaluation time; the `rule_resolution_snapshots` row that each evaluation references (`resolution_snapshot_id`) holds the entity's resolved facts, not the scope-resolution result.

| Mechanism | Behavior |
| --- | --- |
| Replace by rule_key at lower scope | When a published rule_version with the same rule_definition.key exists at a lower scope (program > brand, season > program > brand), the lower-scope version is selected and the higher-scope version is suppressed for that evaluation context. Only one wins per (key, scope chain). |
| Suppression by disabled lower-scope rule | A lower-scope record can mark a rule as is_active = false (e.g. season_rule_overrides.disabled). The rule is dropped from the resolved set entirely; the snapshot records it under suppressed_rule_definition_ids with the suppressing scope. |
| Parameter cascade | rule_parameter_defaults provide the baseline; brand_rule_defaults_v2 / program_rule_configs / season_rule_overrides_v2 supply per-scope replacements. Compliance-locked parameters refuse program/season overrides at write time. |
| Cascade admin routes | `GET /api/brands/:brandId/rule-defaults` lists a brand's `brand_rule_defaults_v2` rows ordered by rule definition; `POST` on the same path upserts one row per brand and rule definition from `ruleDefinitionId`, `parameterOverrides`, `pinnedRuleVersionId`, `disabled` and `notes` (200 with the row, 400 `VALIDATION_ERROR` for a bad payload). Both need the `compliance.rules.author` permission (API keys: `compliance.read` or `admin.read` for GET, `compliance.write` or `admin.write` for POST). The program and season equivalents are `/api/programs/:programId/rule-configs` and `/api/seasons/:seasonId/rule-overrides`, which also run the compliance-lock checks. |
| Provenance per rule | For every rule selected, the snapshot records which scope won, which scopes were considered, and (for suppressions) which scope disabled it. This is the data that powers 'why is this rule running / not running for program X?' answers. |
| Bundle pinning | Resolution is performed against the policy_bundle_version active at evaluated_at. Future-effective rule_versions inside the bundle are excluded if their effective_from is later than evaluated_at. |

> **Compliance Floor Still Applies**
>
> Brand → Program → Season precedence governs business rules and parameter values. Compliance rules sit above the cascade: a season cannot suppress a compliance rule, and compliance-locked parameters cannot be replaced at lower scopes. Attempted writes that would weaken or suppress a compliance-locked rule are rejected at the API layer with HTTP 422 and reason code RULE_PARAMETER_LOCKED — never silently dropped. See Season-Override Rejection Contract for the exact response envelope and deep-link behavior.

## Operational Sequence

The rules engine participates in the full operational lifecycle from program setup through applicant completion:

## Every Lifecycle Touchpoint

*Status: most reason-code and fact names in the phase lists below (for example `AGE_INELIGIBLE`, `NATIONALITY_NOT_ALLOWED`, `GUARDIAN_CONSENT_MISSING`, `applicant.housing.verified`, `ds2019.issued`, `finance.sevis_fee_cleared`) are illustrative design names; `SEVIS_CHECKIN_OVERDUE` is one that shipped. The shipped vocabularies are `server/seed/reasonCodeCatalog.ts` and `shared/factsCatalog.definitions.*.ts` (for example `MINIMUM_AGE_NOT_MET`, `applicantAge`); no guardian-consent or housing fact exists.*

The engine evaluates at every phase transition and at key points within phases. See the Applicant Lifecycle for the full phase model. Every touchpoint below is expressed as a rule binding attached to the named phase gate; fact keys used are the canonical ones from the Facts Catalog.

### Pre-Lifecycle (Setup Chain)

Program created with baseline rule bindings in Program Register / Season instantiated with optional parameter overrides / Workflow designed in Workflow Studio; configuration_integrity rules validate coverage for all compliance bindings / Publish check: effective rule coverage must be complete (coverage gaps are reported as `BUNDLE_RULE_*` errors; `WORKFLOW_COMPLIANCE_COVERAGE_GAP` is not emitted) → publish allowed. Any gap in a non-business rule → publish refused with a validation error (HTTP 400) listing the uncovered rules. / Season opened → applicants can enter; entity_policy rules begin firing
### Phase 1 &rarr; 2 (Application &rarr; Validation)

Completeness: mandatory fields and Wave 1 documents submitted (documents.wave_complete(1)) / Application type validation (J-1, J-2, Program Extension, Program Renewal — each has distinct required fields) / Age eligibility: applicantAge within min/max (AGE_INELIGIBLE) / Nationality eligibility: applicant.nationality in approved_countries list (NATIONALITY_NOT_ALLOWED) / Guardian consent (if under 18): documents.guardian_consent.verified (GUARDIAN_CONSENT_MISSING)
### Phase 2 &rarr; 3 (Validation &rarr; Interview)

Staff approval or automated eligibility decision / For program renewals: good standing check against prior participation / No open Compliance Cases blocking advancement
### Phase 3 &rarr; 4 (Interview &rarr; Placement)

Sponsor interview completion (if required by program rules) / Host interview accepted (if required by program rules) / Active placement confirmed: placement.confirmed = true (PLACEMENT_NOT_CONFIRMED) / Wave 2 documents complete — medical, financial (documents.wave_complete(2), WAVE_DOCUMENTS_INCOMPLETE)
### Phase 4 &rarr; 5 (Placement &rarr; Visa & SEVIS)

SEVIS pool readiness: passport, forms, and confirmed placement complete / SEVIS fee cleared: finance.sevis_fee_cleared (SEVIS_FEE_NOT_CLEARED) / Background check valid on program start (time_window operator, BACKGROUND_CHECK_EXPIRED) / J-2: cross_entity primary_of → primary J-1 placement confirmed (J2_PRIMARY_NOT_APPROVED)
### Phase 5 &rarr; 6 (Visa & SEVIS &rarr; Pre-Departure)

DS-2019 issued: ds2019.issued = true (DS2019_NOT_ISSUED) / Visa obtained and passport still valid through program end + buffer (PASSPORT_EXPIRES_BEFORE_PROGRAM_END) / DS-2019 issuance fee cleared: finance.ds2019_fee_cleared (DS2019_FEE_NOT_CLEARED) / No open finance exceptions (FINANCE_EXCEPTION_OPEN) / J-2: cross_entity primary_of → primary J-1 ds2019.issued (J2_PRIMARY_DS2019_MISSING)
### During Phase 7 (In Program — Continuous Evaluation)

SEVIS validation check-in within configured window after arrival (SEVIS_CHECKIN_OVERDUE) / Monthly check-in compliance — count operator with consecutive flag; threshold triggers SEVIS_CHECKIN_MISSED_STREAK → case opens / Housing verification (leaf over applicant.housing.verified) / Travel request validation (cross_entity to trip record + time_window for dates) / For program extensions: max duration, remaining time, compliance standing
### Phase 7 &rarr; 8 (In Program &rarr; Completion)

Program end_date reached OR early termination triggered / Early termination requires a Compliance Case resolution before SEVIS closure / SEVIS record closure with correct end reason (compliance rule) / Exit survey completion (business rule — advisory, does not block) / Cascade: J-1 termination triggers J-2 terminal closure via cross_entity rules
### Exception Paths (Any Phase)

## Job Order Compliance

Job orders submitted from the Host Portal are also subject to engine evaluation. Bindings attach rules to job orders via entity_type = "job_order".

## Camp Host Verification Policy

*Status: design spec, not built as described. No `camp_host_verification` rule set, `host.document.*` facts, `HOST_DOC_*` reason codes or `host_verification_incomplete` DS-2019 guard exist in code. Host verification today is the `host_verifications` table (one row per host organization): the automated run is advisory (`recommended_status`) and only a human compliance decision sets `status` to verified or failed.*

Host verification for the **Camp** and **Camp SWT** program types is a Rules Engine policy — an entity_policy rule set evaluated against the host organization — not a static document-center checklist. The Host Document Center owns document upload, review, and expiry tracking; this policy owns the verification *decision*. A host with either camp program type must satisfy the same four document rules before its verification can pass: Business License, Workers Compensation, Camp Accreditation, and Terms and Conditions. Expressing verification as policy makes the decision auditable (every evaluation persists its facts snapshot and rule versions), versioned (requirement changes are new rule versions, never mutations), consistently enforced at the camp DS-2019 gate, and insulated from per-season configuration.

> **Documents are evidence, not the decision**
>
> The four artifacts are evidence facts read through the Facts Resolver. Reviewer decisions on individual documents (approve / reject / resubmission-requested) remain the Document Review team's responsibility and are unchanged; the policy consumes the resulting review state as a fact. Enforcement of the derived outcome — refusing DS-2019 issuance — belongs to the existing camp issuance guard, not to the Document Center and not to the rules themselves. The human review surface where those document decisions (and the section-level compliance approvals built on top of them) are recorded is the compliance workbench on Host Verification & Approved Terms — it feeds these facts and never writes the derived outcome.

### Required-Document Rule Set

The rule set key is `camp_host_verification`. It contains four entity_policy rules, each with `entity_type = "host_organization"`. Every rule applies the same verification standard: the artifact must be **present**, **reviewer-approved**, carry an **expiration date** that has not passed as of evaluation time, and carry the **policy information** needed to evaluate its current validity (issuer / policy number / coverage details, accrediting body, or accepted-agreement version, per artifact).

| key | Name | rule_category | rule_class | Evidence facts | Reason code on failure |
| --- | --- | --- | --- | --- | --- |
| host_doc_business_license_valid | Business License Valid | business | workflow_completion | host.document.valid("business_license"), host.document.expires_on("business_license"), host.document.policy_info("business_license") | HOST_DOC_BUSINESS_LICENSE_INVALID |
| host_doc_workers_comp_valid | Workers Compensation Valid | business | workflow_completion | host.document.valid("workers_comp"), host.document.expires_on("workers_comp"), host.document.policy_info("workers_comp") | HOST_DOC_WORKERS_COMP_INVALID |
| host_doc_camp_accreditation_valid | Camp Accreditation Valid | business | workflow_completion | host.document.valid("camp_accreditation"), host.document.expires_on("camp_accreditation"), host.document.policy_info("camp_accreditation") | HOST_DOC_CAMP_ACCREDITATION_INVALID |
| host_terms_and_conditions_accepted | Terms and Conditions Accepted | business | applicant_action | host.terms_and_conditions.accepted, host.terms_and_conditions.expires_on | HOST_TERMS_NOT_ACCEPTED |

> **Classification is business until legal establishes a regulatory basis**
>
> All four rules carry rule_category = business (business_subclass = operational). None is classified regulatory because legal policy has not established a regulatory citation for these host artifacts, and regulatory_basis is required on regulatory-category rules — the catalog does not invent citations. If legal later establishes a basis for a specific artifact, that rule is reclassified via a new version through the standard publish lifecycle; nothing in this policy pre-commits the classification.

The Terms and Conditions rule reads the host's accepted operating Terms and Conditions (the acceptance record documented under Host Portal governance — Operating Agreement — Portal Acceptance), not an uploaded file. Acceptance of the agreement is the evidence; this policy does not change agreement authoring or signature flows.

### Verification Evidence Facts

The parameterized host-document facts and the Terms and Conditions facts are described here as part of the Canonical Facts Catalog (the host.document.* and host.terms_and_conditions.* rows); neither family exists as a key in the code facts catalog yet. host.document.valid(category) is a derived fact: document present AND review state approved AND expires_on not passed at evaluation time. Facts carry the standard provenance metadata, so a stale or conflicted document fact drives the decision to indeterminate / review, never to a silent pass.

### Derived Verification Outcome & DS-2019 Consumption

Each evaluation of the rule set produces a standard engine decision — pass, fail, or review_required — recorded durably with its facts snapshot and rule versions:

- **pass** — all four rules pass.
- **fail** — one or more artifacts is missing, not approved, or expired. The decision carries the per-rule reason codes above as actionable remediation guidance (which artifact, and why it failed).
- **review_required** — facts are indeterminate or conflicted (fail_safe_mode = hold_for_review), or a reviewer escalation applies. Routed to the Compliance Review Queue / sponsor review per the standard queue emission contract.

> **Mapping to the portal `verification_status` enum — normative**
>
> The Host Portal bootstrap contract (`/api/host/me/context`) exposes `verification_status` as verified | pending | failed. The engine decision maps onto it as follows, and this mapping is the single guard input:
>
> | Engine decision | Portal verification_status | DS-2019 guard conjunct | Operational effect |
> | --- | --- | --- | --- |
> | pass | verified | satisfied | Issuance open; no banner. |
> | fail or review_required (remediation in flight) | pending | not satisfied — 409 `host_verification_incomplete` | Full operational access continues under an accepted agreement; reason codes drive the remediation view. An engine fail alone never raises the blocking flag. |
> | sponsor records a final failed verification outcome (escalating a fail / review_required decision) | failed | not satisfied — 409 `host_verification_incomplete` | Blocking flag raised; operations halt per the Camp Verification Failure Path. |
>
> `hostVerificationPassed(host)` in the camp DS-2019 issuance guard is therefore true exactly when the latest recorded decision is pass (portal status verified). The portal-facing **failed** state is a sponsor-recorded terminal outcome, not an automatic consequence of an engine fail — preserving the existing rule that pending camp hosts operate while remediating.

The decision is re-evaluated event-wise when its evidence changes — document uploaded, replaced, approved, rejected, or expired, or Terms and Conditions accepted — per the standard re-evaluation strategy. The existing camp behavior documented on the Host Portal page is preserved unchanged: a **pending** camp host with an accepted operating agreement operates the portal fully while DS-2019 issuance is refused with `host_verification_incomplete`; a **failed** outcome raises a blocking flag and halts operations per the Camp Verification Failure Path. This policy supplies the *derived result* those guards consume; it does not alter their semantics, the expiry-warning timing, notification events, document-review states, or compliance-hold behavior.

### Program-Type Binding — No Season Scope

The policy is scoped by rule_scopes to `entity_type = host_organization` with `program_model_type IN (camp, camp_swt)` — a program-type binding, deliberately without any season dimension. Bindings trigger evaluation at the verification lifecycle points (verification run on the pending host, evidence-change re-evaluation, and the DS-2019 issuance gate's read of the derived outcome).

> **Seasons never touch verification**
>
> Creating, editing, or closing a season neither recreates the four requirements nor makes a valid document invalid. An artifact's validity comes only from its own facts — review state, expiration date, and policy information — never from season configuration. Seasonality remains fully in force everywhere else (cohorts, applicant eligibility windows, placements, agreements); only the host verification policy is non-seasonal.

## Capacity Management & Waitlisting

Capacity rules are operational business rules — not compliance. When a program or season reaches its capacity limit the engine works with the Capacity Engine to decide whether new applicants should be waitlisted or rejected. Capacity checks are made by the Capacity and Waitlist engine (`evaluatePlacement`) against per-(program, season) capacity rows, not by rules-engine leaf predicates.

Waitlist Mechanics

Capacity check evaluated on application submission / When capacity reached + waitlist_enabled: applicant waitlisted / Waitlisted applicants remain in lifecycle phase but excluded from placement queues / De-waitlisting is a deliberate staff action — no auto-promotion
Threshold Events

80% capacity: early warning event to ops team / 100% capacity: waitlisting begins automatically / Events feed into Queue System and notification workflows / Staff can increase capacity to release waitlisted applicants
> **No Auto-Promotion**
>
> Waitlisted applicants are never automatically promoted when capacity opens. Ops staff must review and explicitly de-waitlist. This prevents capacity race conditions and ensures staff oversight.

## Compliance Review Queue

Decisions whose outcome is review_required, indeterminate with fail_safe_mode = hold_for_review, or fail with a permitted override policy, are routed to the Compliance Review Queue — scoped to compliance officers and super admins. This queue uses the shared queue architecture (queue_definitions entity-agnostic model).

## UI Surfaces

The engine exposes nine first-class UI surfaces. Each has defined entry points, primary actions, required RBAC, and empty/loading/error states. Together they let compliance officers author, simulate, observe, and govern the full engine without leaving the application.

### Rule Builder

Entry: Governance & Operations → Rules → “New Rule” / “Edit Rule”. Also from Evaluation Inspector “Open rule version”.

Primary actions: Visual rule-tree editor with drag-to-nest all/any/not, inline if/then/else, predicate picker with autocomplete, derived-fact builder, count & time-window helpers, cross-entity navigator. Sidebars for Scope, Parameters, Output Contract, Actions, Override Policy.

Per-action editor: the Actions sidebar lists one row per action. Each row has an action_type selector (fail actions BLOCK / WARNING / REVIEW_QUEUE / AUTO_REJECT / REQUIRE_DOCUMENT; the stored vocabulary also has AUTO_APPROVE, CREATE_TASK and APPLY_TAG, which the picker does not offer as fail actions), a reason-code picker that shows active codes only (archived and deprecated codes are excluded; the selection persists to rule_actions.reason_code_id), and the per-action queue-routing fields — queue_key, severity, blocks_transition, internal_message, and applicant_message. Selecting a reason code seeds its default severity and suggested actions, which the author can then adjust per action. Publish is blocked client- and server-side if any publishable action has no active reason code.

Roles: compliance.rules.author to draft rules, compliance.rules.publish to publish them and compliance.rules.assign to attach them to a program scope; compliance.rules.manage (leadership) to classify existing rules as compliance; program_rules.edit for business rules within scope.

Classification routes: `POST /api/admin/rules/compliance-rule-definitions/classify` marks an existing rule key as compliance-mandated by inserting a `compliance_rule_definitions` row (201). It needs `compliance.rules.manage`, a stricter key than the `compliance.rules.author` used by the generic rule routes, and an API scope of `compliance.read` or `admin.write`. The body has `ruleKey`, `regulatoryBasis` (3 to 500 characters), `effectiveDate`, `scope` (`global` by default, or `brand`, `program` or `program_type` with the matching `brandId`, `programId` or `programType`, else 400), optional `applicableVisaTypes`, `applyMode` (`new_applications_only` by default, or `retroactive`) and optional `reason`. `regulatory_reference` is resolved server-side from the citation map, never taken from the client, and a `compliance_rule.classified` audit row records the basis, effective date and retroactivity. `GET /api/admin/rules/compliance-rule-keys` (`compliance.view`, optional `programId` query) returns `ruleKeys`, the compliance rule keys resolved for that program.

States: empty = new rule template with placeholder tree; loading = skeleton tree + sidebar stubs; error = tree validation panel highlights offending node with jump-to-fix.

Links: “Dry-run” opens Simulation Runner seeded with the current draft. “Impact Preview” before activation.

### Facts Browser

Entry: From any entity detail page (“View Facts”). From Evaluation Inspector (“Show facts for this evaluation”). From Rule Builder leaf node (“Test this fact”).

Primary actions: Browse canonical facts for an entity with full provenance — collected_at, source, freshness/confidence/conflict badges. Filter by fact key, source system, quality state. Replay against a historical FactsSnapshot.

Roles: compliance.rules.author. Facts never expose raw PII beyond what the caller has rights to via RBAC on the source entity.

States: empty = “No facts resolved yet — trigger resolver”; loading = per-source skeletons; error = resolver failures reported per source with retry.

### Evaluation Inspector

Entry: From Compliance Review Queue, Compliance Case, Audit Log, or entity timeline. Deep-linkable via ?evaluation_id=….

Primary actions: Replay any past evaluation deterministically. Show the annotated tree with per-node pass/fail/indeterminate badges, resolved facts, version stamps. Side-by-side diff against another evaluation or against the same inputs under current policy.

Roles: compliance.view (GET /api/admin/rules/evaluations).

States: empty = “Select an evaluation”; loading = tree and facts pane skeletons; error = version mismatch banner when FactsSnapshot schema is no longer replayable with current engine, with option to simulate.

### Override Governance Console

Entry: Governance & Operations → Overrides. Also contextually from queue rows and cases.

Primary actions: Request new override (inline wizard capturing reason_code, justification, risk, scope, standing flag, expiry). Approve/reject pending overrides — including second-approver path for dual_approval rules. List standing overrides with review_due_at. Renew, revoke, or escalate on review.

Roles: compliance.review.manage (primary and secondary approval: POST /api/rules-engine/overrides/:id/approve-primary and /approve-secondary). Second approver must be a different user for dual_approval.

States: empty = “No pending override requests”; loading = list skeleton; error = policy mismatch banner when a rule’s override_policy has changed since request submitted.

### Simulation Runner

Entry: From Rule Builder (“Dry-run”). From Impact Preview (“Run simulation”). Standalone from Governance & Operations → Simulations.

Primary actions: Select target cohort (season / program / brand / custom entity list). Pick proposed rule version or full policy bundle. Engine runs pure-function replay with trigger_source = simulation — no actions fired, no cases opened, no notifications. Results: count of pass/fail/review_required/indeterminate/error per rule, and the set of entities that would flip outcome compared to current policy.

Roles: compliance.rules.author (phase-gate simulation, POST /api/rules/phase-gate/simulate).

States: empty = “Configure cohort and policy”; loading = progress bar with “N of M entities processed”; error = partial failures per entity with drill-in.

### Policy Impact Preview

Entry: From Rule Builder before activation. From Reason Code Admin before retirement. Automatically shown in the activation confirmation dialog for compliance rules.

Primary actions: Shows affected programs, seasons, and applicants at the moment of activation. Projects expected failure count, review queue growth, and case volume based on live facts. For retroactive changes, shows the set of existing passes that would flip to fail, with option to open Compliance Cases for each.

Roles: compliance.rules.author, compliance.rules.publish or compliance.rules.govern (the Policy Impact sidebar entry at /rules/impact is shown to holders of any of the three).

States: empty = “No impact — rule affects no live entities”; loading = summary skeleton; error = simulation failed with retry.

### Reason Code Admin

Entry: Governance & Operations → Reason Codes.

Primary actions: Create, version, deprecate, and archive reason codes — moving a code through the active → deprecated → archived lifecycle. Edit title/description/severity/default actions. View “used by” list of rule actions and versions. Deprecation/archive wizard requires a successor code and warns that any rule action still pointing at the code will hard-block on its next publish.

Roles: compliance.rules.author to deprecate reason codes (reserved for compliance staff; PM-style callers are refused).

States: empty = catalog bootstrapped from seed; loading = list skeleton; error = conflict with existing active rule versions when deprecating — lists blockers.

### Compliance Case Workspace

Entry: Governance & Operations → Compliance Cases. From any evaluation record with an open case. From entity timeline.

Primary actions: View case detail with all related evaluations, overrides, queue items, evidence attachments, correspondence. Change status (open → investigating → awaiting_info → ready_for_decision → resolved/escalated). For the criminal-background review case the decision dialog records one of the four adjudication outcomes (see Criminal-background adjudication above). Attach evidence, add notes, reassign owner, pause/resume SLA, record resolution outcome.

Roles: compliance.review.manage for owners; compliance.console.view for watchers.

States: empty = “No open cases”; loading = detail skeletons; error = stale SLA banner when paused_at exceeds configured max.

### Audit Log Viewer

Entry: Governance & Operations → Audit Log. Filter from any rule, entity, case, or user page.

Primary actions: Immutable chronological log of every rule evaluation, override request/approval, case transition, reason-code change, and policy-bundle activation. Full-text search, time-range filter, export.

Roles: audit_log.view — broad read-only audit role.

States: empty = “No audit entries in range”; loading = paged skeleton; error = partial index unavailable with retry.

### Identity Field Audit on Staff PATCH

Shipped Aug 11 2026 (Aug 11 2026 release capsule — Workflows 2, PR #1786). Any time a staff member edits a core identity field on an applicant — firstName, lastName, dateOfBirth, nationality, passportNumber — via the admin API PATCH, the change is recorded in the audit log with a staff_identity_edit event type, including a before/after snapshot. This closes a gap where identity corrections were untraceable, and the events are queryable from the Audit Log Viewer alongside every other governed action.

## Override Authority (RBAC Summary)

The per-rule override_policy (see Override Governance ) composes with role-level RBAC. Every override is recorded in rule_overrides with full audit trail.

| Role | Permission | Can Override | Requirements |
| --- | --- | --- | --- |
| Compliance Users | compliance.review.manage (apply or review immediately); eligibility:override:create (request only) | Any rule with override_policy = permitted / dual_approval / temporary_only | Reason code + justification + risk + scope. Second approver for dual_approval. Expiry for temporary_only. |
| Super Admins | super_admin | Same as compliance users; cannot override forbidden-class rules, except that the current published version of an applicant compliance/regulatory rule is always overridable (a missing or 'forbidden' policy resolves to permitted) | Same governance requirements apply. No special bypass of the per-rule override_policy. |
| Program Managers | program_rules.edit | Business rules within their scope only; never compliance | Adjusts parameters and bindings, not override decisions on entity evaluations. |
| All Other Users | — | None | Read-only on evaluations per RBAC scope. |

## Rule-Type Catalog (programRuleVersions Cutover)

The eligibility engine originally modelled 8 authorable rule types, while the legacy program_rule_versions row carried ~24 keys driving SEVIS cadence math, profile completeness, and a long list of operational gates. Cutover ticket #209 (Asana 1214131179452076) could not retire the legacy reader safely until the new engine recognised every key, with a typed parameter schema, a canonical resolved shape, and a documented default-fallback that matches today’s legacy semantics. The catalog below closes that gap. The authoritative machine-readable list of rule types and their Zod parameter schemas lives in `shared/ruleParameterSchemas.ts` (`RULE_TYPES`). The table below records the original legacy-key design, and several rows differ from what shipped: j2_dependents ships as `j2_dependents_policy` (allowed only), extensions and renewals ship as `lifecycle_policy`, monthly_check_ins takes only enabled, and sponsor_capacity takes required, consumed_at_issuance and waitlist_enabled. Where the table and `RULE_TYPES` disagree, `RULE_TYPES` wins.

### Authorable Rules vs Engine Settings

Each legacy key was classified as either an authorable rule (belongs in the Rule Builder catalog with a Zod parameter schema) or an engine setting (a pure operational threshold that belongs on the scoped settings surface, not the Rule Builder). Forcing operational thresholds into the rule catalog would have polluted the Compliance Console with non-authorable knobs, so the SEVIS cadence numbers and sponsor capacity ride a separate settings projection with the same brand &rarr; program &rarr; season scoping rules.

| Legacy Key | Classification | Resolved Shape | Default Fallback | Primary Consumers |
| --- | --- | --- | --- | --- |
| requiredApplicantFields | | fields: string[], profileCompletenessWeight: number | Empty fields list, weight = 1.0 | profileCompleteness, applicantPortal/intake |
| monthly_check_ins | | enabled, cadenceDays, graceDays | enabled=true, 30 / 7 days | onProgramCheckinEngine, queueSystem |
| j2_dependents | | allowed, maxDependents, requiresPrimaryApproval | allowed=false | dependentLifecycle, sevisAmendments |
| extensions | | allowed, maxExtensionMonths, requiresSponsorReview | allowed=false | programExtensionEngine, finance/EXTENSION_FEE |
| renewals | | allowed, cooldownMonths, maxLifetimeRenewals | allowed=false | programRenewalEngine, finance/RENEWAL_FEE |
| fees | | programFeeCents, extensionFeeCents, renewalFeeCents, waivers[] | All fees 0, no waivers | financeNetsuite, applicantPortal/fees |
| orientation | | required, completionDeadlineDays, gateBlocking | required=true, deadline=null, gateBlocking=false | workflowEngine, applicantPortal/journey |
| flight_details | | required, dueDaysBeforeStart, gateBlocking | required=true, due=14d, gateBlocking=false | arrivalsContract, hostPortalArrivals |
| housing_info | | required, dueDaysBeforeStart, carryForwardOnRenewal | required=true, due=14d, carryForward=false | jobOrders, hostPortal/housing |
| insurance | | required, minCoverageUsd, minCoverageDurationMonths | required=true, no minimum | documentsVerification, complianceGates |
| passport_validity | | required, minMonthsBeyondProgramEnd, gateBlocking | required=true, 6 months, gateBlocking=true (DOS regulatory) | documentsVerification, sevisCheckinEngine |
| prior_participation | | allowed, cooldownMonths, maxPriorPrograms | allowed=true, cooldown=0, max=null | eligibilityCheck, matchingEngine |
| documents_verification | | enabled, blocking, routeToQueue, requiredCategories[], autoApproveOnCleanScan | enabled=true, blocking=false, routeToQueue=null | documentReview, complianceReviewQueue |
| references | | enabled, minCount, requireProfessional, gateBlocking | enabled=true, minCount=0, gateBlocking=true (0 = rule disabled at runtime) | applicantPortal/references, complianceGates |
| interview | | required, outcomes[], gateBlocking | required=true, full outcome set, gateBlocking=true | interviewQueue, complianceGates |
| placement | | autoConfirmReservation, reservationHoldHours, requireSponsorApproval | autoConfirm=false, hold=72h, requireApproval=true | matchingEngine, reservationEngine |
| sevis_checkin_due_days | | days: number | 3 days (DEFAULT_INTERNAL_SLA_DAYS, used when unset) | sevisCheckinEngine |
| sevis_critical_escalation_day | | day: number | Day 20 | sevisCheckinEngine, escalationRules |
| sevis_urgent_escalation_day | | day: number | Day 25 | sevisCheckinEngine, escalationRules |
| sevis_regulatory_deadline_days | | days: number | 30 days (DOS regulatory window) | sevisCheckinEngine, complianceGates |
| sponsor_capacity | | maxParticipants, waitlistEnabled, earlyWarningPercent | max=null (unbounded), waitlist=true, warn=80% | capacityEngine, queueSystem |

### Default-Fallback Contract

> **Defaults Must Match Legacy Reader Byte-for-Byte**
>
> For each new rule type, the “scope has no published rule set” path must produce the same default the legacy programRuleVersions reader produces today. Any deviation will silently change behavior in profileCompleteness, sevisCheckinEngine, documentsVerification, and the finance fee derivation. The defaults table above is the contract — the parity comparator below is the enforcement.

### Parity Comparator Coverage

The parity script that runs during the cutover compares the legacy reader’s output to the new resolved snapshot per (brand, program, season). Comparison goes through the NEW_CANONICALIZERS registry — one canonicalizer function per rule type, deterministic and pure. Every rule type in `RULE_TYPES` (`shared/ruleParameterSchemas.ts`) has an entry in the registry; the guard test LEGACY_RULE_KEYS_WITH_CANONICALIZERS fails the build if a rule type is added without a matching canonicalizer. Several keys in the catalog table above are registered under different rule-type names (for example j2_dependents_policy, lifecycle_policy, fee_requirement, pre_departure_check, references_check, interview_requirement and placement_settings), and others (for example requiredApplicantFields and passport_validity) have no registry entry. The registry is published from `shared/ruleCanonicalizers.ts` (not `shared/schema.ts`) so the rules-engine repo can import it directly.

### Legacy Read Sites — Cutover Complete

> **This cutover has shipped. The section below is retained as history, not as outstanding work.**
>
> - ~~server/services/rulesEngine.ts~~ — the legacy reader — was **deleted on 2026-04-23 by Task #348**. It no longer exists; do not cite it.
> - The `program_rule_versions` table and the dual-read compatibility layer were **removed by Task #354**. `shared/schema.ts` carries only a tombstone comment where the table used to be declared.
> - Rules are now sourced exclusively from the eligibility-rules engine (Rules Engine V2). `server/services/newEngineRuleAdapter.ts` and `server/services/eligibilityRulesEngine.ts` both record the removal of the `programRuleVersions` fallback in-source.
> - Of the six readers originally listed for migration, `profileCompleteness`, `sevisCheckinEngine`, and `capacityEngine` remain as services in `server/services/` (now reading the resolved snapshot); `documentsVerification`, `financeFees`, and `onProgramCheckinEngine` no longer exist as standalone service modules.

## Regulatory Rule Bundles

Regulatory Rule Bundles are reusable, versioned packages of compliance rules that the Compliance team authors centrally and the platform auto-attaches to programs based on visa-type predicates. They are the answer to: "how does a brand operations user spin up a new J-1 program without having to know which 14 federal compliance rules must be on it, and which parameter values are non-negotiable?"

### Bundle Concept & Why

Without bundles, every new program would need a brand operator (or worse, a brand admin) to manually pick the correct compliance rules out of the catalog, set their parameters, and toggle the lock — a workflow that is fragile, audit-hostile, and impossible to keep aligned when Department of State guidance changes. Bundles invert this:

- Compliance authors a bundle once per regulatory regime (e.g., J-1 Standard, J-2 Dependents, Extension/Renewal Add-On).
- The bundle resolver matches incoming program metadata against each bundle's `condition_predicates` JSONB (keys `visa_type`, `program_model_type`, `allows_dependents` and `category_code`; these are predicate keys, not columns of regulatory_rule_bundles) and picks the highest-precedence applicable bundles.
- Matched bundle rules are written into program_rule_configs with compliance_locked = bundle_rule.lock_required (per-rule, not blanket-true) and provenance fields (bundle_id, bundle_version). Where the bundle marks a rule lock_required = true, the lock cannot be removed below compliance authority; where the bundle marks it false, the rule is bundle-provenanced but parameter-editable at brand/season scope.
- When Compliance publishes a new bundle version, every affected program is flagged with bundle_update_available = true — migration is explicit, never silent.
### Bundle Shape & Identity

A bundle is a (key, version) pair. key is the stable regulatory identity (e.g., j1_standard); version is a monotonically increasing integer. Each bundle row carries:

| Field | Purpose |
| --- | --- |
| bundle_key + version_number | Identity. (bundle_key, version_number) is unique. The pair is recorded in program_rule_configs.lock_source (bundleId, bundleKey, bundleVersion) for locked rules. |
| name, description | Human-readable label and rationale shown in the Bundle Author and the Program Rules surface. |
| condition_predicates.visa_type | Predicate the resolver matches against the program's visa type (a string or a list, e.g., J-1, J-2, F-1). Stored inside the condition_predicates JSONB, not as a column. |
| condition_predicates.program_model_type | Optional refinement against the program's model type (a string or a list, e.g., camp, intern, trainee). Omitted means any model. |
| condition_predicates.allows_dependents | Optional boolean predicate. Omitted means any; true / false matches the program's dependent posture. |
| condition_predicates | JSONB envelope of declarative predicates the resolver evaluates against program metadata: visa_type, program_model_type, category_code and allows_dependents. An empty object matches every program. Bundles with attach_mode = manual skip predicate matching and attach through bundle_scopes rows instead. |
| precedence_band | Resolver ordering. Higher band wins when multiple bundles match (e.g., a J-1 Camp Counselor add-on can refine J-1 Standard). See Bundle Precedence Numbering. |
| status | draft → published → archived. Only published bundles attach to new programs. Once published, the (bundle_id, bundle_version) row is immutable. |
| cloned_from_bundle_id | Provenance for version updates — the new version always clones from the previous published version's payload. |
| publish/archive metadata | published_at / published_by / publish_reason and archive equivalents are written by the Bundle Author and feed regulatory_bundle_publish_audit. |

Each member rule lives in regulatory_bundle_rules: a row per (bundle_id, rule_definition_id) with default_parameters and a lock_required flag. Bundles reference rule_definitions (identity), not rule_versions — runtime evaluation always picks the currently-published rule version per the standard cascade.

### Bundle Resolver — Visa-Type Auto-Attach

The Bundle Resolver is a pure function. Precedence is numeric high-wins: higher number = more specific / stronger precedence (see Bundle Precedence Numbering ).

resolveBundles(program: )
-> Bundle[] // ordered: highest precedence first (numeric DESC)
steps:
1. SELECT * FROM regulatory_rule_bundles
WHERE status = 'published'
AND attach_mode = 'auto'
2. Evaluate condition_predicates against program metadata; drop non-matching bundles.
3. For each (key) group, keep only the highest version with status = 'published'.
4. Sort compliance bundles before business bundles, then by precedence_band DESC (high-wins), then version_number DESC.
> **Determinism**
>
> The resolver is purely a function of program metadata + the published bundle table at resolution time. It is called by the program-create hook, by the Bundle Author preview, and by the Compliance Console "what would attach" simulator — same inputs always yield the same ordered bundle list.

### Auto-Attach on Program Create

The Program Register's program-create transaction calls the resolver and writes one program_rule_configs row per (rule, bundle) match in a single transaction:

BEGIN;
INSERT INTO programs (...) RETURNING id;
bundles := resolveBundles(program_metadata);
FOR EACH bundle IN bundles:
FOR EACH bundle_rule IN bundle.rules:
INSERT INTO program_rule_configs (
program_id, rule_definition_id, pinned_rule_version_id, parameter_overrides,
disabled, compliance_locked, locked_parameter_keys, lock_source,
bundle_update_available, notes
) VALUES (
program.id, bundle_rule.rule_definition_id, bundle_rule.pinned_rule_version_id, bundle_rule.parameter_overrides,
false, bundle_rule.lock_required, bundle_rule.locked_parameter_keys when lock_required else empty,
lock_source (kind, bundleId, bundleKey, bundleVersion, lockReason) when lock_required else NULL,
false, 'auto-attached by bundle ' || bundle.bundle_key || ' v' || bundle.version_number
ON CONFLICT (program_id, rule_definition_id) DO UPDATE
SET every column above is overwritten with the winning bundle rule's values;
COMMIT;
Conflicts (a rule appearing in multiple matched bundles) are deterministic: the higher-precedence bundle wins, and compliance_locked is upgraded but never downgraded. The transaction also writes one row per bundle to a program-level provenance log (audit_logs) so the Program Register surface can show "this program was created with bundle X v3 + bundle Y v1 attached".

### Immutability & Compliance Lock

- Bundle row immutability. Once a bundle row transitions to status = 'published', the row and its regulatory_bundle_rules children become read-only. Any change requires cloning to a new version.
- Program rule lock. Every program_rule_configs row written from a bundle has compliance_locked = true when the bundle marked lock_required. The Brand and Program rule surfaces render these as read-only with a "Locked by regulatory bundle v" badge.
- Lock removal authority. Only Compliance can remove a bundle-derived lock, and only by either (a) republishing the bundle without the rule, then migrating the program, or (b) issuing a documented exception via the standard override flow against the underlying rule. There is no UI path for a brand operator to clear the lock.
- Parameter immutability for compliance params. Parameters whose rule_parameter_defaults declare them compliance-managed cannot be overridden at brand or season scope when the rule arrived via a bundle.
### Version Updates & Migration Flow

Bundle updates are an explicit, reviewable workflow — never a silent reach-in across hundreds of programs:

- Clone. Compliance opens the published bundle in the Bundle Author and clicks "Create new version". The system copies the row to version + 1 with status = 'draft' and cloned_from_bundle_id populated.
- Edit. Compliance adds, removes, or re-parameterizes member rules in the new draft version. The current published version remains unchanged and continues to attach to new programs.
- Diff. The Bundle Author shows a structured diff: rules added, rules removed, rules with changed default_parameters, and the count of programs currently on the prior version.
- Publish. Compliance publishes the new version with a publish reason. The system writes regulatory_bundle_publish_audit with the diff summary and affected program count, archives the prior version's attach eligibility (the prior row stays for replay; new programs only get the new version), and sets bundle_update_available = true on every program_rule_configs row whose (bundle_id, bundle_version) matches the prior version.
- Per-program migration. The Program Rules surface and Compliance Console both surface a "Bundle update available" banner per affected program. Only Compliance Officers (compliance.bundles.manage) can apply a migration; brand admins (programs.manage) can only request one via POST /api/programs/:id/bundle-migrations/request, which a Compliance Officer then approves and executes. The system applies the diff to that program's program_rule_configs rows and writes a program_bundle_migrations row recording the diff, requestor (if any), executor, and reason. See Migration Authority & Dual-Approval for the full contract; migration is a single transaction per program.
> **Why per-program migration, not platform-wide rewrite?**
>
> Compliance changes that flip a fact for an applicant who has already started a phase transition can open Compliance Cases. By migrating one program at a time with an explicit actor and reason, every cascade is auditable and timed by the operator — not by a background job. Programs that have not yet been migrated continue evaluating against their current bundle version's parameters until explicitly migrated.

### Compliance Console Bundle Author

The Bundle Author is a Compliance-Console surface (RBAC scope: compliance.bundles.manage) with the following panels:

- Bundle list. All bundles grouped by key, with status chips for draft / published / archived, current published version, attach count (number of programs currently bound), and "update available" count for prior-version programs.
- Predicate panel. Edit visa_type, program_model_type, allows_dependents, and the condition_predicates JSONB editor. A live "matched programs" preview re-runs the resolver against an in-memory snapshot of programs and shows a count plus a sample list.
- Rule membership editor. Add/remove rule definitions, set default_parameters per rule (validated against rule_parameter_defaults), toggle lock_required, and reorder.
- Diff view. When a draft is descended from a published version, render the structured diff (added / removed / re-parameterized) and the impact preview (programs that would be migrated).
- Publish dialog. Publish requires a publish_reason text field and a confirmation that the affected program count has been reviewed; emits regulatory_bundle_publish_audit.
- Migration tracker. A cross-program list of programs flagged with bundle_update_available, batched by bundle, so Compliance can drive migrations down to zero before the next publish.
### Bundle API Surface

> **Documented contract — implemented in Globus**
>
> These endpoints describe the contract Globus exposes to the architecture-portal pages and to other ecosystem clients. They are not implemented in this documentation site. Detailed regulatory rule logic, member-rule definitions, and publish history require compliance.bundles.read on the Globus side; the read scopes below describe minimum scope needed to call the endpoint successfully.

| Endpoint | Purpose | RBAC |
| --- | --- | --- |
| GET /api/regulatory-bundles | List bundles with filters (status, visa_type, key). Returns the applied/locked summary (key, version, predicates, precedence, attach + update-available counts) — no member-rule logic. | compliance.bundles.read.summary (summary); compliance.bundles.read (full row) |
| GET /api/regulatory-bundles/:id | Bundle detail with member rules, regulatory citations, default parameters, and current diff vs. previous published version. | compliance.bundles.read (detail) |
| POST /api/regulatory-bundles | Create a new bundle key in draft status. | compliance.bundles.manage |
| POST /api/regulatory-bundles/:id/clone-version | Clone a published version to a new draft version (cloned_from_bundle_id populated). | compliance.bundles.manage |
| PATCH /api/regulatory-bundles/:id | Edit a draft bundle's predicates, name, description. Rejected with 400 (VALIDATION_ERROR) on non-draft bundles. | compliance.bundles.manage |
| POST /api/regulatory-bundles/:id/rules (add a member rule) and DELETE /api/regulatory-bundles/:id/rules/:ruleDefinitionId (remove one) | Edit the rule membership of a draft bundle. | compliance.bundles.manage |
| POST /api/regulatory-bundles/:id/publish | Transition draft → published with publish_reason. Writes audit row and flips bundle_update_available for prior-version programs. | compliance.bundles.manage |
| POST /api/regulatory-bundles/:id/archive | Archive a published bundle (no new attaches; prior attaches keep current parameters). | compliance.bundles.manage |
| POST /api/regulatory-bundles/resolve | Pure resolver endpoint — given program metadata, return the ordered bundle list that would attach (key, version, precedence, attach reason). Returns summary fields only, no detailed rule logic. | compliance.bundles.read |
| POST /api/programs/:id/bundle-migrations/:migrationId/apply | Approve and apply a requested per-program migration. The body carries a role of primary or secondary: a primary approval marks the migration approved, and a secondary approval from a different user (not the primary approver and not the requester) performs the apply and records the diff on the program_bundle_migrations row. See Migration Authority & Dual-Approval for the brand-initiated flow. | compliance.bundles.manage (sole executor) |
| POST /api/programs/:id/bundle-migrations/request | Brand admin requests a migration; creates a program_bundle_migrations row with status requested. No program_rule_configs are written until approval. | programs.manage |
| GET /api/programs/:id/bundle-migrations | List migration history for a program (summary fields). | compliance.bundles.read |

### Unified Operational Gate Management

Shipped Sep 2026. Operational gates — Market Ready, Ready for Placement, Ready for SEVIS and later lifecycle checkpoints — are now authored in the **same** Rules Library / Bundles workspace as regulatory and business bundles: choose **Operational Gates** in the type selector. The catalogue comes from the Rules Engine gate definitions (classification `operational_gate`), not from applicant-facing visibility and not from ordinary bundle records, so progressive display on the applicant Compliance tab never hides a gate from staff authoring.

- **Definition, not bundle.** An operational gate is a Rules Engine definition that aggregates eligible **published** regulatory and business rules into a lifecycle checkpoint. Its prerequisite rows each name a published rule, its accepted result, category, order and mandatory/informational behaviour. The picker refuses duplicates, unpublished rules, gate-to-gate references and out-of-scope references with a clear message.
- **Draft / publish safety.** Published gate versions are immutable. **Start draft** clones the live version (or resumes an existing draft — a second competing draft is not created); only the draft's prerequisite editor is editable, every change is saved and audited, and the publish check must pass before publication. Publication is the activation point: readiness and effective-rule projections refresh without a restart, and editing a draft never changes applicant readiness. Published SEVIS-gate changes also invalidate stored readiness verdicts (see [SEVIS Readiness](/docs/sevis-readiness)).
- **Scope.** An empty or unconfigured scope is a configuration problem, not a reason to hide the gate.
- **Applicant Compliance visibility policy.** A gate version can carry `parameters.gateConfig.visibilityPolicy`: `always`, or `progressive` with a required `earliestPhase` and an optional `predecessorFamily`. The canonical family order is `verified_legal_identity`, `ready_for_placement`, `ready_for_sevis`, `ready_for_visa`, `ready_for_departure`, `ready_for_arrival`, `ready_for_completion`; a gate with no authored policy takes the family default (the first three `always`, the rest progressive after the previous family). The validator reports `INVALID_MODE` (mode is neither `always` nor `progressive`), `INCOMPLETE_POLICY` (the policy does not match the schema, for example a progressive policy with no canonical earliest phase or with a predecessor that is not a canonical family, or unexpected keys) and `FORWARD_PREDECESSOR` (the predecessor is the gate's own family or a later one, so self and cyclic dependencies are refused). Creating a rule version or patching its `parameters` with an invalid authored policy returns 400 `VALIDATION_ERROR` ("Invalid Applicant Compliance visibility policy") with the issues in `details.issues`; the publish check reports the same codes under gate `VISIBILITY_POLICY`. A stored policy that fails validation is ignored at read time and the default applies.
- **Only published rules evaluate.** Gate dependencies are pinned by rule definition id, so archiving a dependency's last published version (or leaving only a draft) would otherwise leave it at not_evaluated and block every application. The evaluator skips any dependency whose definition has no current published version (or no definition at all): it is excluded from dependencies, failedDependencies and the verdict, and reported on the result's unevaluatableDependencies field with reason no_published_version or definition_missing, plus a warning log naming the gate version and rule keys. Open breaches recorded by rules that are no longer published are ignored, including by the gate-level blockOnActiveBlockedOutcome check. The gate status stays pass — a rule that cannot run must not block an applicant — but the compliance projection sets systemFault ("System Problem"), words the row as "Advancement is allowed, but a rule behind this gate is not published and was excluded from the check", and lists each excluded rule with an instruction to republish it or remove it from the gate. Authoring is not guarded: archiving a rule a published gate depends on is still allowed.
- **Market Ready family (fail-closed).** The market_ready family (ruleClass marketing) decides host-portal exposure. It currently holds one global baseline gate, market_ready.j1_all_programs, with three prerequisites: profile completion (workflow_completion.profile_completion_gate.j1_all_programs.p11_4), minimum age, and authoritative sponsor screening (compliance.sponsor_screening_cleared.j1_all_programs). Program-scoped Market Ready checklists and terms-and-conditions acceptance are no longer prerequisites, and no passport or SEVIS dependency participates. Stacking is retained as a mechanism: every applicable gate must pass. The verdict fails closed — a not_evaluated dependency fails its gate, a missing published gate blocks, and an engine outage blocks. The sponsor-screening rule is deliberately not auto-attached to programs, so the service evaluates it explicitly against the applicant's facts snapshot and injects the result before the gate runs; an unresolvable, unpublished or erroring screening rule leaves the dependency not_evaluated and blocks. Applications outside the host market (by legacy programme or placement_source, e.g. self-arranged) are not exposed and receive no Market Ready grant.
- **Host Portal enforcement.** Discovery surfaces (recommended and participant search) include only applications with a persisted application_gate_statuses row for gate family market_ready with status ready, applied as a set-based EXISTS before pagination; a missing, blocked or waiting row hides the applicant. Write paths (host recommendation, and reservation create per the code comments) call the live assertMarketReady and re-wrap any failure as a host-safe MARKET_NOT_READY 422 ("This participant is not currently available to reserve.") that leaks no blocker detail. Staff-side callers receive the full blocker list under reasonCode MARKET_READY_PREREQUISITES_NOT_MET.
- **Seeding.** The sponsor-screening rule and the Market Ready gate family are authored by reference boot seeders (seedRulesEngineSponsorScreeningRule, seedRulesEngineMarketReadyGates). Boot-time seeding is skipped where SKIP_SEEDS=true, which is set for prod.
- **Ready for Placement family (Task 2483).** By policy placement carries no prerequisites beyond Market Ready: if a host can see a participant, the host can reserve and hire that participant. The family (`ready_for_placement`, exported as `PLACEMENT_GATE_FAMILY_KEY`) holds exactly one gate, the global `ready_for_placement.j1_all_programs`, authored from the same shared prerequisite list as `market_ready.j1_all_programs` (`server/seed/readinessGatePrerequisites.ts`): profile completion, minimum age and sponsor screening, all hard-pass and fail-closed. The programme-scoped placement gates (`ready_for_placement.camp_counselor_wildpacks`, `ready_for_placement.swt_camp_wildpacks`, `ready_for_placement.teach_usa`) and the Wild Packs Operational Gates bundle are retired (published version archived, published pointer cleared, publish audit written). Programme qualification (current student status, academic calendar, vacation window, references and reference review, the J-1 Teacher checks) is left in place and still blocks in the separate ready_for_sevis chain, so a failing programme prerequisite blocks later in the journey and is never waived. A parity test fails if the two gates' dependency sets drift or a programme-scoped placement gate reappears. Because profile completion and sponsor screening are bundle-inert, `evaluatePlacementReadiness` now evaluates and injects their live outcomes exactly as Market Ready does (shared `injectInertBundleDependenciesForGates` and `INERT_BUNDLE_ONLY_DEPS` in `inertBundleRuleInjection.ts`). The seeder (`ready-for-placement-gates-v4`) and a paired idempotent data migration (`20271125000000_task_2483_align_placement_ready_with_market_ready`, needed because environments that boot with SKIP_SEEDS=true never run the seeder) drop every persisted `ready_for_placement` projection row (the seeder through the family-generic `invalidateAllGateStatusesForFamily`, only when it changed the gate set; the migration with a direct delete), so the sweeper recomputes verdicts; a missing row reads as waiting, never ready, and reserve and hire evaluate the gate live regardless. Self-arranged placements are exempt from Market Ready exposure but still pass the placement gate, so they now also require sponsor screening and no longer require terms acceptance.
- **Host-safe placement refusal (Task 2484).** A placement readiness refusal raised inside the reservation or placement writers carries internal rule keys, rule names, dependency reasons and queue routing. Every host entry point that can reach it now maps it to a 422 with code `PLACEMENT_NOT_READY`, a host-readable message and `details.reasonCode` only (no blocker list); the full detail goes to the server log and the staff readiness surfaces are unchanged. Covered entry points: host reservation create, host-level hold convert and hire, host placement create and confirm-update, and the partner-API reservation create (its `reservations.write` scope is reachable by a host). Other rejections (agreement, quota, capacity, conflict) keep their own envelopes. `PLACEMENT_NOT_READY` is part of the reservation hireability reason-code vocabulary with separate host and staff wording, but `resolveReservationHireability` never returns it: the routes attach it to the refusal envelope.
- **Seeding (placement).** The Ready for Placement gate is authored by `seedRulesEngineReadyForPlacementGates`, which runs after the sponsor-screening rule and the Market Ready gates because it shares their dependency set. The separate Teach USA placement gate seeder is removed.
- **API (additive, effective `2026-10-28` in the change registry).** `GET /api/rules-engine/definitions` accepts `operational_gate` as a classification filter and returns published/draft version metadata plus prerequisite counts for gate rows; `GET /api/rules-engine/definitions/:id` includes caller-visible version scopes. Prerequisite authoring continues to use the existing gate-dependency routes.
- **Gate prerequisite routes and refusals.** `GET /api/rules-engine/versions/:id/gate-dependencies` lists a version's prerequisites; `POST` on the same path adds one, and `PATCH` / `DELETE /api/rules-engine/gate-dependencies/:id` edit or remove a row in place. Writes need `compliance.rules.author` and are refused with `400 VALIDATION_ERROR` when the version is not a draft. Adding a prerequisite is refused with `400 VALIDATION_ERROR` and `details.code` `NOT_OPERATIONAL_GATE` (the owning definition is not classified `operational_gate`), `GATE_DEPENDENCY_SELF_REFERENCE`, `INELIGIBLE_GATE_DEPENDENCY` (the referenced definition is not classified compliance or business) or `DEPENDENCY_NOT_PUBLISHED` (it has no current published version); a rule already listed on the version is `409 CONFLICT` with `details.code` `GATE_DEPENDENCY_DUPLICATE`.

### Seed Bundle: J-1 Standard v1

*Status: illustrative design, not seeded data. The spec list in `server/seed/regulatoryBundles.ts` (`REGULATORY_BUNDLE_SPECS`) defines `j1-standard` (precedence_band 100, one member rule `j1_core_compliance_gate`) plus the J-1 bundles `j1-baseline`, `j1-au-pair`, `j1-camp-counselor`, `j1-intern`, `j1-trainee`, `j1-teacher` and `j1-swt` at precedence_band 300, but its `seedRegulatoryBundles` seeder is disabled in the boot seed list (`server/seed/index.ts`). Bundle keys use hyphens, and the 14-rule member list below is not defined there.*

The platform ships with one published seed bundle so every new J-1 program acquires the baseline regulatory rule set automatically:

key — `j1_standard` 
version — `1` 
name — J-1 Standard Compliance 
status — 
visa_type — `J-1` 
program_model_type — `NULL (any model)` 
allows_dependents — `NULL (any)` 
precedence — `300` (visa-category band — see [Bundle Precedence Numbering](#nc-precedence)) 
Member rules (all lock_required = true):

- passport.verified_before_visa — passport verification gate before SEVIS DS-2019 issuance.
- ds2019.issued_before_program_start — DS-2019 must be issued prior to program start date.
- sevis.checkin_within_required_days — SEVIS check-in window enforcement (default 10 days from arrival).
- monthly_checkin.required — monthly check-in cadence on for the duration of program participation.
- insurance.coverage_active — required health insurance coverage active for entire program window.
- host.site_visit_required — site visit obligation for new host placements.
- placement.confirmed_before_arrival — placement must be confirmed before arrival fact is logged.
- extension.requires_compliance_approval — program extension routes through Compliance even when extension_allowed is true.
- renewal.requires_compliance_approval — program renewal requires Compliance sign-off.
- document_review.complete_before_arrival — document_review.wave_complete fact must be true before arrival.
- sevis.record_status_active — applicants on program must hold sevis.record_status = ACTIVE.
- finance.payment_cleared_before_ds2019 — applicant finance status must be cleared before DS-2019 issuance.
- host.status_active — host placements only against host.status = active hosts.
- program_completion.required_documents_received — program closure gate on required completion documents.
Default parameters (e.g., the 10-day SEVIS check-in window) come from each rule's rule_parameter_defaults and may be overridden in the bundle's regulatory_bundle_rules.parameter_overrides JSONB when sponsor policy differs from a rule's catalog default.

## Normative Contracts

This section is the single source of truth for the wire-level contracts that other portals, services, and admin surfaces must obey when interacting with the Rules Engine and Regulatory Rule Bundles. Where any other page in this portal disagrees with what is written here, this section wins.

### Bundle Precedence Numbering

Bundle precedence is a small integer stored on regulatory_rule_bundles.precedence_band (one of 100, 200, 300, 400, 500; other values are rejected on create). The resolver sorts numeric DESC — higher number wins. The reserved bands below are normative and must be used when authoring bundles so the merge order is predictable across visa regimes:

| Band | Intent | Example |
| --- | --- | --- |
| 100 | Sponsor defaults (`sponsor_defaults`), the lowest band. | j1-standard (defined in `server/seed/regulatoryBundles.ts`) |
| 200 | Program band (`program`). | - |
| 300 | Regulatory standard (`regulatory_standard`), the band of the per-category J-1 bundles. | j1-baseline, j1-camp-counselor |
| 400 | Regulatory exception (`regulatory_exception`). | teach-intern-trainee-compliance |
| 500 | Federal mandate (`federal_mandate`), the highest band. | - |

> **Compliance floor beats precedence**
>
> Numeric precedence orders bundles relative to each other. The compliance floor is absolute: no higher-precedence bundle, brand override, program config, or season override may weaken a compliance-locked rule. The merge upgrades a lock but never downgrades it. Operational (business) rules merge under high-wins; compliance rules merge under high-wins-then-lock-monotone.

### Bundle & Lock Reason Codes

Reason codes are stable, machine-readable identifiers returned in API error envelopes, decision objects, and the publish-gate response. Clients should switch on these codes — never on human-readable messages. The full Rules Engine reason-code catalog lives in the Reason Code Catalog ; the codes below are the bundle / lock subset that PR 2 introduces.

| Code | When emitted | HTTP |
| --- | --- | --- |
| RULE_PARAMETER_LOCKED | A POST/PATCH on season_rule_overrides (or partner_rule_overrides) attempts to set a parameter value or flip enabled = false on a rule whose program-level config is compliance_locked = true. Distinct from a 403: a 403 means the caller lacks scope to touch the rule at all; a 422 RULE_PARAMETER_LOCKED means the caller has scope but the requested write violates the compliance lock. Direct PATCH on a compliance-locked program_rule_configs row by a non-compliance caller returns 403 (RBAC) before this code is reached. | 422 |
| BUNDLE_RULE_UNBOUND | Workflow Studio publish gate: a bundle-attached compliance rule has zero rows in program_rule_coverage_bindings for the workflow being published. | 422 (publish), surfaced in publish-gate envelope |
| BUNDLE_RULE_BINDING_STEP_MISSING | A program_rule_coverage_bindings row points at a workflow_step_id that is no longer in the workflow's step list (orphaned binding after step removal). | 422 (publish) |
| BUNDLE_RULE_BINDING_SOURCE_RETIRED | A binding points at a system_process source whose contract has been retired in the System Process Registry. | 422 (publish) |
| BUNDLE_RULE_BINDING_FACT_GAP | A bound step or system_process does not produce all of the rule's required_fact_keys (covered-but-incomplete). | 422 (publish) |
| BUNDLE_RULE_BINDING_FACT_DROPPED | A previously published binding's underlying fact is no longer produced (regression after step edit). | 422 (publish) |
| BUNDLE_VERSION_MISMATCH | A migration apply call references a target version that is not the latest published version for the bundle key (lost-update guard). | 422 |
| BUNDLE_NOT_PUBLISHED | An attach or migrate call resolves to a draft or archived bundle. | 422 |
| BUNDLE_LOCK_DOWNGRADE_REJECTED | An author tries to publish a bundle version where a previously lock_required = true rule is now lock_required = false. Lock removal requires the explicit unlock workflow. | 422 |
| `OPERATIONAL_GATE_READ_ONLY` (in `details.code` under `FORBIDDEN`) | Any write through the regulatory-bundle API (create, lifecycle, rule attach or detach, predicates, scopes) that targets an operational-gate bundle. Gate bundles are system-provisioned and read-only there. | 403 |
| `BUNDLE_OWNER_REQUIRED` | Creating a business bundle with neither `ownerBrandId` nor `ownerProgramId` when none can be derived from the caller's single-program scope; also a scope attach by a bundle-manage-only caller on a business bundle that has no owner. | 422 |
| `BUNDLE_OWNER_AMBIGUOUS` | Creating a business bundle with both `ownerBrandId` and `ownerProgramId`. | 422 |
| `BUNDLE_OUT_OF_SCOPE` | A caller without the assign bypass attaches scopes to a compliance bundle, or to a business bundle owned by a program or brand outside their scope; or creates a business bundle for a brand outside their scope. | 403 |
| `BUNDLE_KEY_EXISTS` (in `details.code` under `CONFLICT`) | Creating a bundle whose `bundleKey` is already used by any version, or losing a concurrent-create race for it; add a version with clone-version instead. | 409 |

### Bundle Publish-Gate Response Envelope

The Workflow Studio publish gate calls POST /api/workflow-versions/:id/validate-coverage before flipping draft → published, and the publish endpoint reuses the same envelope on rejection. On bundle coverage failure the response is HTTP 422 with the envelope below — the workflow stays in draft and the Studio renders the failures as a stable copyable list (never a toast). The detailed UX contract for this surface lives on Workflow Builder — Bundle Publish Gate ; this section is the canonical wire envelope.

POST /api/workflow-versions/:id/validate-coverage
→ 422 Unprocessable Entity
errors / business_rule_warnings / workflow_version_id / wfv_7 / program_id / prog_camp_counselor_alpha
- A non-empty errors[] array produces the 422; business_rule_warnings never block publish on their own (each warning carries a message).
- Each error's reason_code is exactly one of the bundle reason codes from the table above; clients switch on the code, never on the message.
- suggested_step_keys is always present (possibly empty) — the Studio uses it to power the "add coverage" action without a second roundtrip.
- The envelope is identical between dry-run validate-coverage and the publish-time rejection so the Studio renders the same UI in both modes.
### Season-Override Rejection Contract

When a season admin tries to write an override that would weaken a compliance-locked parameter or disable a compliance-locked rule, the API rejects the write. The response is always HTTP 422 with the envelope below — there is no silent drop, no partial success, and no auto-clamp.

POST /api/seasons/:season_id/rule-overrides
rule_definition_id / rd_min_age / parameters
→ 422 Unprocessable Entity
ok / code / RULE_PARAMETER_LOCKED / rule_definition_id / rd_min_age / rule_key / min_age / season_id / sea_summer_2026 / program_id / prog_camp_counselor_alpha / locked_parameters / min_age_years / lock_source / deep_link / /programs/prog_camp_counselor_alpha/rules?focus=min_age&from=season-rejection / message / min_age_years is locked by regulatory bundle j1_standard v1; season cannot weaken it.
- locked_parameters lists every parameter in the request that is locked — partial-OK writes are not supported, the entire write is rejected if any parameter is locked.
- lock_source.kind is one of regulatory_bundle, compliance_authored, or brand_compliance_lock; clients use it to choose the right "manage lock" link.
- deep_link is the canonical URL the season UI surfaces in the rejection toast — landing on the program Rules tab focused on the offending rule and surfacing the rejection notice (see Season Overrides Roll-Up).
- The same envelope is returned for the equivalent PATCH on existing season override rows.
### RBAC Scope Catalog

These are the scope identifiers used throughout the bundle and program-rules contracts. They sit inside the platform's standard RBAC model (see RBAC ) and are the canonical names other ecosystem portals must use when checking permissions against Globus.

| Scope | Grants | Typical role |
| --- | --- | --- |
| compliance.rules.author | Draft new eligibility rule definitions in Program Rules. | Compliance Admin, Program Manager |
| compliance.rules.manage | Classify compliance rules as a leadership reviewer (reason required). | Compliance Admin |
| compliance.rules.publish | Publish a new version of a regulatory rule (reason required). | Compliance Admin |
| compliance.rules.assign | Attach a published rule to a specific program scope (reason required). | Compliance Admin |
| business.rules.manage | Move a Business Rule Set through its forward lifecycle edges (draft to published to deprecated to archived) via `POST /api/rules-engine/business-sets/:id/transition`; compliance.rules.author also authorises these edges. A holder of only this key must have program or brand scope covering the set. | Program Manager, Brand Admin, Compliance Admin |
| compliance.rules.govern | Restricted, reason required. Override (rollback) edges on a Business Rule Set (published to draft, deprecated to published, archived to deprecated), each with a free-text reason and not limited by set scope; also hard-deletes a rule definition (`DELETE /api/rules-engine/definitions/:id`, reason of 8 to 2000 characters, 409 `RULE_HAS_OVERRIDES` while applicant overrides reference it). Not held by Compliance Officer. | Compliance Admin |
| compliance.program_mapping.view | Read-only view of the effective-dated SEVIS program-number to program-type mapping and its resolution (GET /api/program-number-mappings and /resolve); grants no edit authority (editing is compliance.program_mapping.manage). | Compliance Admin |
| compliance.bundles.manage | Create, edit drafts of, publish, and archive regulatory_rule_bundles. Sole executor of bundle migrations. | Compliance Officer / Super Admin |
| compliance.bundles.read | Full detail read on bundle membership, rule-version history, and publish audit. Required to render the Bundle Author detail panel. | Compliance Officer |
| compliance.bundles.read.summary | Read the applied/locked summary view of attached bundles (key, version, precedence, lock count). Does not expose member rule logic. | Brand Admin, Program Manager |
| program_rules.edit | Toggle business rules and edit business-rule parameters within declared bounds. Cannot touch compliance-locked rows. | Program Manager |
| programs.manage | Create or edit a J-1 program's configuration; also the scope for requesting a bundle migration (writes a requested program_bundle_migrations row - does not apply the migration). | Brand Admin |

### Version Coexistence & Deprecation

Multiple published versions of the same bundle key may coexist indefinitely; the resolver always selects the highest-version published row at program-create time, but already-attached programs keep their original version until they are explicitly migrated. The lifecycle invariants below are normative:

- Status transitions are draft → published → archived. There is no published → draft; mistakes are corrected by publishing a new version.
- Published bundles are immutable. Predicates, member rules, default parameters, and lock_required flags are frozen on publish; edits create a new draft version.
- Archived bundles do not auto-detach. Programs already attached to jX_v3 keep running it after jX_v3 is archived; the Compliance Console surfaces a "stuck on archived version" report so migrations can be planned.
- Resolver excludes archived versions from new attaches — only published rows participate in resolveBundles.
- Bundle update available (program_rule_configs.bundle_update_available = true) is set on every existing row whose bundle key publishes a new version, scoping the migration backlog.
- Deprecation notice is a deprecation_reason + deprecated_at pair set when a bundle is archived. The Bundle Author surfaces it on every still-attached program so brand admins understand why a migration is being requested.
### Migration Authority & Dual-Approval

Bundle migrations rewrite compliance-locked program rules and so are gated by an explicit authority model. The contract below is normative for both the Globus implementation and any ecosystem portal that proxies a brand admin's request.

| Action | Who | Effect |
| --- | --- | --- |
| Apply migration (writes program_rule_configs) | compliance.bundles.manage only | Atomic transaction: snapshots prior config, replaces locked-rule rows with the new bundle version's defaults, stamps bundle_version, clears bundle_update_available, writes program_bundle_migrations audit row. |
| Request migration (no writes to configs) | programs.manage | Writes a program_bundle_migrations row with status requested. Appears in the program's migration list (GET /api/programs/:id/bundle-migrations) for a compliance approver. |
| Approve & execute a brand request | compliance.bundles.manage | Same as direct apply, but audit row records both requestor and approver. This is the documented dual-approval flow. |
| Reject a brand request | compliance.bundles.manage | Marks the request rejected with a justification; no rule writes occur. |
| Bulk migrate across programs | compliance.bundles.manage | Same per-program transaction repeated; partial failures are reported per-program and never roll back successful migrations. |

> **Dual-approval is the brand-initiated path**
>
> Brand admins never directly write to program_rule_configs for bundle-attached rules. The brand-initiated path always goes through POST /api/programs/:id/bundle-migrations/request; a compliance approver then executes POST /api/programs/:id/bundle-migrations with the request id, which is the dual-approval audit linkage. Direct compliance-driven migrations skip the request step but record only one party in the audit row.

## Data Model

The data model is decomposed so each concern has a dedicated table with clear relationships. Identity (rule_definitions) is separate from versioned payload (rule_versions) so rules can evolve without breaking historical replay. Tables are declared under `shared/models/` (for example `shared/models/rules-sponsor.ts`) and re-exported from `shared/schema.ts`.

| Table | Purpose | Key Fields |
| --- | --- | --- |
| rule_definitions | Immutable rule identity & classification | key, name, family (entity_policy / configuration_integrity), rule_category (regulatory / business), rule_class, business_subclass, regulatory_basis, current_version_id, rule_group_key |
| rule_versions | Versioned policy payload — one row per version, immutable once published | rule_definition_id, version_number, status (draft / published / archived), cloned_from_rule_version_id, rule_tree (JSONB: RuleTreeNode), parameters (JSONB), applicant_failure_message, fail_safe_mode, reason_code_id, effective_from, effective_to, published_at, published_by_user_id, archived_at, archived_by_user_id |
| rule_version_publish_audit | Append-only audit trail of every rule version state transition | rule_version_id, rule_definition_id, from_status, to_status, changed_by_user_id, actor_role, reason, diff_summary (JSONB), test_cases_passed_count, test_cases_total, policy_bundle_version_after, ip_address, user_agent, changed_at |
| rule_scopes | Scope filters per rule version | rule_version_id, lifecycle_phases, application_types, entity_type, brand_scope, program_scope |
| rule_bindings | Attachment points — phase gate, publish check, schedule, etc. | rule_version_id, binding_kind (phase_gate / publish_check / scheduled / data_mutation / manual), target_ref |
| rule_parameter_defaults | Default parameter values per rule version | rule_version_id, parameter_key, parameter_type, default_value |
| rule_evidence_bindings | Canonical facts read by each rule version — powers Facts Browser and impact preview | rule_version_id, fact_key, required (bool) |
| rule_actions | One row per action with first-class action-level columns — each carries its own reason code and queue-routing fields | rule_version_id, action_type (BLOCK / WARNING / REVIEW_QUEUE / AUTO_APPROVE / AUTO_REJECT / REQUIRE_DOCUMENT / CREATE_TASK / APPLY_TAG), reason_code_id (FK reason_codes), queue_key, severity (blocking / warning / review / informational), blocks_transition (bool), internal_message, applicant_message |
| rule_outcomes | Structured outcome emitted per fired action (consumed by Action Orchestration; persisted for replay & explainability). When queue_key is present it creates or updates the applicant's queue item. | applicant_id, rule_id, rule_version_id, rule_action_id, action_type, reason_code_id, queue_key, severity, blocks_transition, internal_message, applicant_message, evaluation_record_id, emitted_at |
| rule_override_policies | Per-rule-version override policy config | rule_version_id, policy_type (forbidden / permitted / dual_approval / temporary_only), max_duration_days, requires_dual_approval, allowed_role_keys, standing_review_days |
| brand_rule_defaults | Brand-level parameter values per rule | brand_id, rule_definition_id, enabled, parameter_values (JSONB) |
| program_rule_configs | Program-level parameters with compliance-lock flag and regulatory-bundle provenance | program_id, rule_definition_id, enabled, compliance_locked, parameter_values (JSONB), locked_by, lock_reason, bundle_id, bundle_version, bundle_update_available |
| regulatory_rule_bundles | Reusable regulatory rule packages (e.g., J-1 Standard v1) matched on visa type and program model through condition_predicates — auto-attached on program create and immutable once published | bundle_key, name, description, condition_predicates (JSONB; holds visa_type, program_model_type and allows_dependents, which are not separate columns), precedence_band, version_number, status (draft / published / archived), cloned_from_bundle_id, published_at, published_by, publish_reason, archived_at, archived_by, created_by |
| regulatory_bundle_rules | Rule membership within a bundle version with default parameters and required-lock flag | bundle_id, rule_definition_id, default_parameters (JSONB), lock_required, sort_order |
| regulatory_bundle_publish_audit | Append-only audit trail of bundle state transitions and republish events | bundle_id, bundle_key, bundle_version, from_status, to_status, actor_user_id, reason, diff_summary (JSONB), affected_program_count |
| program_bundle_migrations | Records explicit per-program migrations from one bundle version to a newer published version | program_id, bundle_id, bundle_key, from_version, to_version, diff_summary (JSONB: rules_added / rules_removed / rules_reparameterized), migrated_by, migration_reason |
| season_rule_overrides | Season-level parameter overrides (non-compliance params only) | season_id, rule_definition_id, parameter_values (JSONB), override_reason |
| compliance_gates | Gate definitions between lifecycle phases (legacy, superseded by rule_bindings kind = phase_gate) | name, from_phase, to_phase, rule_definition_ids, application_types |
| reason_codes | Governed reason code catalog with explicit lifecycle status — only active codes are selectable per-action and only active codes pass the publish gate | code, version, status (active / deprecated / archived), category, severity, title, description, suggested_actions (JSONB), emits_notification, opens_case, deprecated_at, archived_at, replaced_by |
| rule_test_cases | Named test cases bound to a rule_definition; pin a synthetic facts snapshot to an expected outcome and failing-rule set | rule_definition_id, name, description, category, synthetic_facts_snapshot (JSONB), resolved_parameters (JSONB), expected_outcome, expected_failing_rule_keys (JSONB), expected_reason_codes (JSONB), is_active, last_run_at, last_run_passed, last_run_id, created_by, created_at, updated_at |
| rule_test_case_runs | Immutable history of every test-case execution; written by publish gate and ad-hoc Test Runner. Every run is pinned to an exact rule_version_id. | test_case_id, rule_version_id, version_number, run_by_user_id, run_at, actual_outcome, actual_failing_rule_keys (JSONB), actual_reason_codes (JSONB), passed (bool), diff_summary (JSONB), evaluation_engine_version, trigger_source (publish_gate / manual / scheduled / ci) |
| rule_evaluation_records | Immutable evaluation audit log with versioning & replay fields (one row per bundle pass). Carries denormalized scope context for fast historical queries. | rule_version_id, entity_type, entity_id, applicant_id, brand_id, program_id, season_id, facts_snapshot_ref, resolution_snapshot_id, decision (JSONB: RuleDecision — authoritative blob), policy_bundle_version, facts_schema_version, evaluation_engine_version, trigger_source, trigger_operation, idempotency_key, evaluated_at |
| rule_evaluation_results | Normalized per-rule child of rule_evaluation_records — one queryable row per rule that participated in the bundle pass (the explainability layer; the JSONB decision blob remains authoritative) | evaluation_record_id, rule_definition_id, rule_version_id, rule_key, result (passed / failed / skipped / overridden / errored), severity, reason_codes (JSONB), applicant_facing_message, internal_message, parameters_snapshot (JSONB), provenance (JSONB: source_scope, inherited_from_rule_version_id, suppressed_higher_scopes), evaluated_at |
| application_rule_open_breaches | Projection of the rule failures currently open on each application, maintained from live evaluation and cleared when the rule passes again. Backs dimension-keyed Exception queue membership and readiness roll-ups. One row per application, rule action and reason code (legacy rows without an action are per rule). | application_id, rule_definition_id, rule_action_id, reason_code_id, dimension, queue_slug, latest_outcome, opened_at, updated_at |
| business_rule_set_metadata | Revision header for a business rule set. `family` is the lineage key; published revisions are immutable and edits fork a draft revision linked by `parent_set_id`. | id, family, lifecycle_state (default draft), revision_number, parent_set_id, owner_user_id, description, brand_id, attached_program_ids, attached_season_ids |
| business_rule_set_rules | Junction giving each rule set revision a curated membership, instead of deriving it from every rule sharing a family. | set_id, rule_definition_id (together the primary key), display_order |
| rule_evaluation_runs | One row per batch eligibility evaluation (nightly scheduler, CLI or manual). Append-only: a rerun creates a new run. A run is marked completed only after every targeted applicant has a result row; a mid-run crash leaves it failed. | status (running / completed / failed), triggered_by (scheduler / cli / manual), started_at, completed_at, duration_ms, total_applicants, counts (JSONB: pass, fail, needs_review, pending, not_applicable, errored), error_message |
| rule_evaluation_run_results | One row per targeted applicant or application per run, carrying the eligibility engine's rule-set evaluation result verbatim. An errored row records that the engine threw for that applicant. | run_id, applicant_id, application_id, rule_set_id, rule_set_version_number, overall_outcome, failure_rule_keys, per_rule_results, counts, error_message, evaluated_at |
| rule_gate_dependencies | Prerequisite rules of an operational gate version. A gate reads the outcomes of other published rules, never applicant fields. Version-scoped and cloned when the gate is republished; a referenced rule cannot be hard-deleted while a gate depends on it. | rule_version_id, dependency_rule_definition_id (unique per version), accepted_outcome (PASS / PASS_OR_APPROVED_REVIEW / NOT_BLOCKING), mandatory (a mandatory dependency that misses its accepted outcome fails the gate; otherwise informational), group_label, sort_order |
| rule_evaluation_node_results | Per-tree-node child of rule_evaluation_records, one row per node. | record_id, node_path (unique per record), node_kind, outcome, subject_key, operator_key, fact_keys, detail (JSONB) |
| season_rule_overrides_v2 | Season and program rule override, the highest-precedence layer of the cascade; writes must respect compliance locks. | season_id, program_id, rule_definition_id (unique together), pinned_rule_version_id, parameter_overrides (JSONB), disabled, reason, approved_by_user_id, approved_at, effective_from, effective_until, created_by_user_id |
| rule_resolution_snapshots | System-generated, publish-time effective-resolution artifact per (brand, program, season). Records which lower-scope rule_version replaces or suppresses which higher-scope rule_version, with override_reason and published_rule_version_id provenance. Never hand-maintained. | id, brand_id, program_id, season_id, policy_bundle_version, triggered_by_rule_version_id, triggered_by_publish_audit_id, resolved_rules (JSONB: per-rule rule_key, published_rule_version_id, source_scope, inherited_from_rule_version_id, replaced_rule_version_ids, suppressed_rule_version_ids, override_reason ), suppressed_rule_definition_ids, computed_at, engine_version |
| rule_overrides | Governed overrides with reason, scope, expiry, approvers | evaluation_record_id, rule_version_id, reason_code, justification, risk_classification, scope, standing_flag, expires_at, primary_approver, secondary_approver, before_state, after_state, review_due_at |
| compliance_cases | Durable compliance cases with owner, SLA, evidence | case_number, opened_reason_codes, entity_type, entity_id, owner_user_id, severity, status, sla_due_at, sla_paused_at, related_evaluations, related_queue_items, resolution_outcome |
| eligibility_evaluation_results | Archive of request-driven single-applicant eligibility rule-set evaluations, used for replay. Written only when the environment variable `RULES_ENGINE_V2_PERSIST_EVALUATIONS` is `on`; a persistence failure is fail-soft and does not fail the evaluation. Batch runs use rule_evaluation_run_results instead. | applicant_id, application_id, rule_set_id, rule_set_version_number, engine_version, overall_outcome, failure_rule_keys, per_rule_results, counts, applicant_snapshot, resolved_snapshot_ids, correlation_id, evaluated_at |

> **Legacy Tables**
>
> compliance_rule_definitions (overlay model) remains in the schema (`shared/models/partnerCompliance.ts`); program_rule_versions (boolean-toggle model) has been dropped (see Legacy Read Sites - Cutover Complete). New code must use the decomposed model exclusively.

### Admin Rules Routes

Staff routes over the brand default, season override, override-record and legacy gate tables. Reads and writes both need the permission shown; API keys need `compliance.read` for reads and `admin.write` (with `compliance.read`) for writes. Writes are audited.

| Route | Permission | Behavior |
| --- | --- | --- |
| `GET /api/admin/rules/brand-defaults`, `GET /api/admin/rules/brand-defaults/:id`, `POST` (201), `PATCH /:id`, `DELETE /:id` | compliance.rules.author | CRUD over brand_rule_defaults. The list takes an optional `brandId`. An unknown id on GET or PATCH is 404; DELETE returns success even when no row matched. |
| `GET /api/admin/rules/season-overrides`, `POST` (201), `PATCH /:id`, `DELETE /:id` | compliance.rules.author | CRUD over season_rule_overrides. The list takes optional `seasonId` and `programId`; `approvedAt`, `effectiveFrom` and `effectiveUntil` are accepted as ISO timestamps. |
| `POST /api/admin/rules/evaluate/:applicationId` | compliance.view | Returns `applicationId` and `resolvedRules` (the rules resolved for that application). Without an id the call is a validation error, and any verb other than POST on the evaluate paths returns 405 `METHOD_NOT_ALLOWED` with `Allow: POST`. |
| `GET /api/admin/rules/overrides` | compliance.overrides.review | Lists rule_override_records (optional `applicationId`) with applicant and creating-staff names. |
| `POST /api/admin/rules/overrides`, `PATCH /api/admin/rules/overrides/:id` | compliance.gates.override | Creates an override record, stamping `overriddenBy` from the caller (201), or updates one. A PATCH with no updatable fields is a validation error; an unknown id is 404. |
| `GET /api/admin/rules/compliance-gates` | compliance.view | Lists compliance_gates, optional `programId`. |
| `POST /api/admin/rules/compliance-gates` (201), `PATCH /:id`, `DELETE /:id` | compliance.rules.author | Manage compliance_gates rows (a legacy table, see Data Model). |
| `POST /api/admin/rules/compliance-gates/check` | compliance.view | Body `applicationId`, `programId`, `fromStageKey`, `toStageKey`; returns the gate check result for that stage transition. |

The `/api/admin/rules/evaluation-results` alias, the `/api/admin/rules/resolve` helper and the `/api/admin/rules/program-rule-versions/*` adapters are retired and no longer registered; use the eligibility rule-set engine at `/api/admin/eligibility/rule-sets/*`.

## Sponsor-Grade Requirements

As a designated J-1 visa sponsor platform, the engine must satisfy Department of State regulatory standards. The features on this page map directly to those standards:

s published version actually fired and what it overrode). Queues, dashboards, and case viewers query this table directly instead of unpacking JSONB blobs.,
Internal vs Applicant-Facing Messaging / Each rule definition carries internal_failure_message (operational, may name fact keys and queues) and each rule version carries applicant_failure_message (applicant-facing — plain-language, action-oriented, no internal detail). Publish lint forbids cross-contamination; both are surfaced in the Compliance Review Queue and Evaluation Inspector.,
Derived Resolution Snapshots / rule_resolution_snapshots are system-generated records of the facts resolved for one entity (entity_type, entity_id, resolved_at, facts_schema_version, the verbatim FactsSnapshot, and overrides_in_force). Snapshots are never hand-maintained — every evaluation references one for replay and provenance.,
Deterministic Replay / Any past decision can be re-run pure-functionally against the persisted FactsSnapshot, resolution_snapshot, and exact rule_version — byte-for-byte reproducibility.,
Authority Restriction / Compliance rules can only be authored by compliance users. Overrides follow each rule
## Asana Project Cross-Reference

The sponsor-grade engine touches 12 of the 117 tracked Asana projects. Each connects through specific integration points:

| Project | Name | Connection |
| --- | --- | --- |
| P3 | Rules Engine & Eligibility | This IS the rules engine project — all five layers plus UI surfaces. |
| P1 | Foundation Hardening | RBAC: compliance.rules.author, compliance.rules.publish, compliance.review.manage, compliance.console.view, audit_log.view. |
| P2 | Applicant Pipeline | Runtime entity_policy evaluation on applicant entry and phase transitions. |
| P4 | Host & Job Order Foundation | Job order entity_policy rules; host.status and daysSinceLastSiteVisit facts. |
| P5 | Matching & Placement Core | Placement-confirmed fact; reservation-to-placement gate rules. |
| P7 | Matching Engine | Hard filters now expressed as entity_policy rules; optimization-subclass rules guide scoring. |
| P10 | SEVIS & Compliance Gateway | SEVIS facts (hasSevisCheckinOverdue, sevisRiskLevel, isInSevisPool, sevisReadinessBlocked); DS-2019 and closure gate rules. |
| P11 | Workflow Studio | configuration_integrity rules: coverage panel, publish checks. |
| P13.1 | On-Program Lifecycle Management | Continuous evaluation, check-in count operators, program extension and program renewal rules, Compliance Cases. |
| P16 | Queue System & SLA | Compliance Review Queue definition; case SLA integration. |
| P18 | Document Review | Document facts (for example passportReadable, passportIdentityApplied, passportUploaded) feed the Facts Resolver. |
| P21 | Applicant Profile Phase 2 | J-2 cross_entity rules; surname-match derived fact; program renewal eligibility. |
