An open-source, parity-tested implementation of the OBBBA Less-Than-Full-Time Reduction (Schedule of Reductions) for federal Direct Subsidized and Unsubsidized loans, with a free public HTTP API.
Built and maintained by Tirath Chhatriwala, Product Manager with over 14 years of experience in EdTech, Higher Education and FSA Regulatory Compliance.
Try it now: sor.myproduct.life
API docs: sor.myproduct.life/api-docs
Source: https://github.com/tirath5u/project-sor
Web UI access: The web calculator is behind a soft access gate during the launch window. Password:
sor2026. The public API endpoints below require no password and no signup.
# Service liveness + version metadata
curl https://sor.myproduct.life/api/public/v1/health
# Published scenario catalog with regulatory citations
curl https://sor.myproduct.life/api/public/v1/scenarios | jq '.scenarios | length'
# Run the first published scenario through the engine
curl -X POST https://sor.myproduct.life/api/public/v1/calculate \
-H 'Content-Type: application/json' \
-d "$(curl -s https://sor.myproduct.life/api/public/v1/scenarios | jq '.scenarios[0].input')"The dollars you get from the API match the dollars you get from the web UI match the dollars in the published fixture catalog. One engine, three views, zero drift.
A reference implementation of the Schedule of Reductions math for award years 2025-26 and 2026-27, defined by 34 CFR 685.203 and amended by the OBBBA. Single source-of-truth engine (src/lib/sor.ts), exposed both as a web calculator at sor.myproduct.life and as a public HTTP API at /api/public/v1/. Fixtures, citations, methodology, and the 5-stage build process are all open and documented.
Not an official U.S. Department of Education publication. Not an Anthology or Ellucian product. Not legal or compliance advice. No real student data is or will ever be in this repo. All scenarios use synthetic personas or federally-published examples.
This calculator is an SOR engine. It does not determine NSLDS remaining aggregate eligibility, lifetime maximum eligibility, Parent PLUS remaining eligibility, consolidation allocation, or final Grad PLUS eligibility. Those ceilings must be resolved before relying on the SOR output.
Always validate against the current COD Technical Reference Volume 2 and the most recent ED Electronic Announcement before making a disbursement decision.
- Reduced annual Sub/Unsub baselines computed from grade level, dependency status, Parent PLUS denial, and optional override caps.
- Graduate and professional Sub/Unsub annual caps including the 2026-27 professional annual Unsub cap for non-grandfathered borrowers.
- Grad PLUS preview only for legacy or interim-exception scenarios. The engine does not model NSLDS aggregate or lifetime remaining eligibility.
- Per-term disbursement amounts with proper rounding-to-dollar correction so the term sum equals the reduced annual amount (no orphan pennies).
- History-anchored disbursement view: committed Paid Sub / Paid Unsub per term anchor independently and the engine redistributes the remaining pool only across future eligible terms.
- Case-file PDF export of inputs, calculated baselines, per-term disbursements, and the 6-step walkthrough.
- 7 canonical fixtures drawn from ED-published scenarios, each tagged with regulatory citations and a source-status label.
| Endpoint | Method | Purpose |
|---|---|---|
/api/public/v1/health |
GET | Liveness, engine version, policy year |
/api/public/v1/scenarios |
GET | Fixture catalog with regulatory citations and source-status labels |
/api/public/v1/calculate |
POST | Run the engine on supplied inputs |
/api/public/v1/openapi.json |
GET | OpenAPI 3.1 specification |
/api-docs |
GET | Human-readable API guide with examples and challenge workflow |
Rate limit: 30 requests per minute and 5,000 per day per IP, best-effort per edge isolate. No keys, no signup. Header X-RateLimit-Policy: best-effort-per-isolate documents the constraint honestly.
Response envelope: every successful response carries the metadata needed to
reproduce a calculation against a specific snapshot of the rules. Top-level
keys: data and meta. The meta object includes:
engineVersion- semantic version of the calculation engine (e.g.1.0.0)policyYear- award year the engine was evaluated against (e.g.2026-27)policySnapshotDate- ISO date of the policy snapshot usedpolicyStatus-confirmedorsupported-preliminarysourceCommit- Git SHA of the deployed engine, orlocal-devfor non-CI builds (see note below)sourceSet- identifiers of the rule packs used (e.g.["direct-loan-sor-v1"])citations- regulatory citations applicable to the result (may be empty)computedAt- ISO timestamp the response was producedrequestId- correlation ID; also returned in theX-Request-Idresponse header
sourceCommitnote. GitHub CI injects the real commit SHA viaVITE_COMMIT_SHAat build time. Deployments triggered from the Lovable editor do not set that variable and will returnsourceCommit: "local-dev". For source-reproducible results, pin against a CI-built deployment.
Error contract: uniform { error: { code, message, details? } } envelope. Status codes are RFC-correct: 400 for malformed JSON, 415 for wrong content type, 422 for valid JSON that fails schema, 429 for rate limit, 405 for wrong method, 413 for oversized body.
- Load test report - measured throughput and tail latency against the live production deployment. Headline: ~16,700 requests across the three public endpoints, zero 5xx, p99 under 175 ms.
- Incident runbook - health check, triage matrix, fixture-replay smoke test, and rollback procedure.
- Contract testing - OpenAPI example replay, exported Postman collection, Newman nightly checks, and mismatch triage rules.
- Security policy - how to report a vulnerability privately.
git clone https://github.com/tirath5u/project-sor
cd project-sor
bun install
bun test42 tests pass cent-exact against published fixtures: 7 SOR parity scenarios plus schema validation plus numeric coercion edges. CI runs the same suite on every push and pull request.
A second verification path is executable contract testing: CI pulls the documented request example from /api/public/v1/openapi.json, posts it to /api/public/v1/calculate, and checks the documented stable fields. The exported Postman collection in postman/ runs nightly through Newman against the live API.
| Audience | What you get |
|---|---|
| Financial aid administrators (FAA) | Plain-English walkthrough of how OBBBA's LT-FT reduction lands on a real student, term by term. |
| SIS / FA developers | A reference engine and parity tests for the SOR formula, rounding rules, and disbursement anchoring you can compare your own implementation against. |
| QA engineers | The fixture catalog as one-click presets, plus a parity test suite covering edge cases (mid-cycle drops, partial entry, override caps). |
| Product managers | A worked example of converting regulatory ambiguity into shippable acceptance criteria, with sources cited inline. |
- Pick a scenario from the left rail or start blank. Fixtures are grouped by case type.
- Adjust inputs: grade level, dependency, annual need, term count, full-time vs. enrolled credits per term, optional summer or winter terms.
- Read the 6-step walkthrough on the right. Every number on screen has a citation back to the input that produced it.
- Switch to Disbursement view to enter Paid Sub / Paid Unsub per term and watch the engine re-anchor the remaining pool.
- Export the PDF for audit, sprint review, or a vendor conversation.
- Statutory annual limits for Sub and Unsub from the grade-level lookup, with optional override.
- Annual financial need is split into Sub-eligible and Unsub-eligible buckets.
- Award-year enrollment intensity is computed from per-term enrolled vs. full-time credits, weighted by term length.
- Reduced annual amounts equal the lesser of the statutory cap or the need bucket, multiplied by the enrollment-intensity percentage.
- Per-term disbursements spread the reduced annual across active terms, with a rounding pass that pushes any cent residual into the last eligible term so the sum is exact.
- Sub and Unsub anchor independently. Entering
Paid Sub = 666for a term must NOT zero out that term'sPaid Unsub. Each loan type has its own history. - Blank is not zero. A
Paid Unsubfield that has not been entered is pending, not committed zero. The engine only redistributes Unsub forward after the user explicitly enters or confirms 0. - Enrollment intensity is award-year-level, not term-level. A student going 12/6/12 is not "full-time, half-time, full-time." They are at one weighted AY percentage.
- Combined limit is the ceiling. Unsub baseline equals
max(0, combinedLimit - subBaseline). Override mode does not let you exceed the lookup combined limit unless you intentionally override that cap too. - Round to dollar at the term level, not the annual level. Per-term values are integer dollars; the rounding residual lands in the last eligible term.
- Mid-cycle disbursement requires history. Once any term is marked Disbursed, the engine treats that term's Paid amounts as locked and only adjusts future terms.
- Grad/Prof has no Sub. Sub baseline is forced to 0 for graduate and professional grade levels regardless of override.
- Aggregate and lifetime checks come first. For graduate, professional, Grad PLUS, Parent PLUS, or lifetime-limit-sensitive scenarios, apply NSLDS remaining limits before using this SOR engine. If Unsub plus requested Grad PLUS exceeds the lifetime ceiling, source guidance says reduce Grad PLUS first, then Unsub.
Every fixture and rule traces back to a source labeled with one of five statuses:
| Label | Meaning |
|---|---|
confirmed |
Published in a Dear Colleague Letter, Electronic Announcement, the COD Tech Ref, or final ED Q&A |
operational-clarification |
Confirmed by ED in a vendor focus group or written response to a vendor question |
inferred |
Derived from regulation text or analogy to an existing rule, not yet published |
pending-federal-guidance |
Known open question; current behavior is the safest default until ED confirms |
school-policy-dependent |
Permitted variability where institutional policy controls the answer |
Full register: docs/public-source-register.md. Methodology: docs/methodology.md. Rounding policy: docs/rounding-policy.md.
If you find a scenario the engine handles incorrectly, open a Scenario Challenge issue with:
- The exact input
- The expected output (with reasoning)
- The observed output
- A regulatory citation supporting the expected behavior
Accepted challenges become fixtures first, code changes second. Issues are triaged weekly.
Regulatory ingest into a domain wiki, LLM-assisted formula derivation into an Excel master sheet, adversarial cross-LLM review on the spec, Lovable-built calculator UI, and a thin TypeScript HTTP wrapper around the same engine. The full workflow including the AI-orchestration role is documented in docs/process.md.
Requires Bun (or use npm / pnpm equivalents).
bun install
bun dev # start the dev server
bun test # run the SOR engine and parity test suite
bun run build # production buildTech stack: TanStack Start v1 (React 19, file-based routing, SSR), Vite 7, Tailwind CSS v4, shadcn/ui, Zod, jsPDF for case-file export.
The calculation engine lives in src/lib/sor.ts with parity tests in src/lib/sor.parity.test.ts. Fixtures are in src/lib/sor.fixtures.ts. Public API routes are under src/routes/api/public/v1/. UI components are in src/routes/index.tsx and src/components/sor/*.
See CONTRIBUTING.md. Pull requests welcome for fixture additions, regulatory-citation updates, and bug fixes. Engine math changes follow the fixture-first rule: open a Scenario Challenge issue, get the new fixture accepted, then submit the code change.
MIT. See LICENSE.
Tirath Chhatriwala, Product Manager, in EdTech and Higher Education with over 14 years of experience.
- Hub: myproduct.life
- This tool: sor.myproduct.life
- LinkedIn: Tirath Chhatriwala
- More projects: https://github.com/tirath5u
Project SOR is the first tool in the myproduct.life umbrella, a small and growing collection of practitioner-built tools, strategy notes, and product-thinking writing.
