Vol. I  ·  No. 292 Established 2026  ·  AI-Generated Daily Free to Read  ·  Free to Print

The Trilogy Times

All the news that's fit to generate  —  AI • Business • Innovation
MONDAY, OCTOBER 19, 2026 Powered by the TrueFoundry AI Gateway  ·  Published on Klair Trilogy International © 2026
🖶 Download PDF 🖿 Print 📰 All Editions
Today's Edition

No Guardrails, Full Throttle

At the UN, Washington chose velocity over consensus — and left the rest of the world to read the room.

NEW YORK — The hall at Turtle Bay has heard every kind of ambition. Few as bare as this one. Donald Trump told the General Assembly the United States would not be slowed by global rules on artificial intelligence, framing the technology as a sprint toward what he called "Super Intelligence" — a prize, in his telling, too valuable to share the rulebook for, let alone the lead.

It was a rejection dressed as a doctrine. While diplomats elsewhere sketch frameworks — the EU's AI Act, UN advisory panels, a patchwork of export-control regimes — Washington's message was that global coordination is a brake pedal, and brake pedals are for other countries.

Beijing is not waiting to be out-sprinted. Analysts tracking the run-up to the next U.S.-China summit describe a widening gap between the two capitals' theories of control — Washington betting speed itself is the safeguard, Beijing building state-directed guardrails domestic and firm. The distance between those two philosophies is where the real contest lives, far more than in any single model release.

Meanwhile, in Washington, the machinery meant to enforce America's half of that bargain is showing cracks. A Commerce Department official charged with policing chip exports to China has become the target of hardliners who call recent enforcement lapses a "massive screw-up" — the kind of bureaucratic infighting that rarely makes headlines abroad but shapes, quietly, whether export controls are porous or real.

The irony sits in plain view. A president declaring no one should slow the race is relying on an agency currently consumed with whether it can even referee its own rulebook. Super Intelligence may arrive on schedule. Whether the guardrails do is a separate, messier question — and increasingly, nobody's.

↗ The global race to put guardrails around AI - hrme.economict  ·  U.S. Initiative Intensifies AI Competition​ - China-US Focus  ·  Donald Trump rejects global AI rules at UN, says US must lea

THIRD CIRCUIT AFFIRMS: SOMEBODY'S HOMEWORK IS NOT, IN FACT, YOUR HOMEWORK, SAYS COURT, RE: AI TRAINING DATA

Pursuant to a precedential ruling, an appeals panel has determined that Thomson Reuters' copyrighted materials remain Thomson Reuters' copyrighted materials, notwithstanding their ingestion by a competing AI system.

PHILADELPHIA — Notwithstanding the aforementioned industry-wide presumption that the wholesale ingestion of copyrighted material for purposes of machine-learning model training constitutes, per se, a protected "fair use" under Section 107 of the Copyright Act (hereinafter, "the Act"), a federal appeals court has, pursuant to its appellate authority, upheld a lower tribunal's finding to the contrary, in what is being characterized, by parties not unsympathetic to the plaintiff, as a "landmark" determination.

The underlying matter, as previously reported, concerns the unauthorized reproduction, by a since-rebranded legal-research competitor, of certain headnotes and editorial syntheses authored by Thomson Reuters, for the express purpose of training a rival artificial-intelligence product. The panel found, in substance, that such use was not "transformative" within the meaning of the Act, inasmuch as the output served a substantially similar commercial function as the input, to wit: assisting legal researchers in locating relevant case law, notwithstanding the intervening algorithmic processes.

Legal commentators have noted, with varying degrees of alarm, that the ruling may be read, per certain trade publications, as foreclosing the broader fair-use defense routinely asserted by large-language-model developers, though it is noted, for the avoidance of doubt, that the ruling's precedential scope remains confined, at present, to the specific facts hereinbefore described.

In seemingly unrelated proceedings before the legislative, rather than judicial, branch, Anthropic has separately proposed to Congress a federal AI safety statute, the implications of which, Section 3.2 notwithstanding, remain to be adjudicated in due course.

↗ US appeals court upholds Thomson Reuters' landmark win in AI  ·  Court Finds That Using Copyrighted Material as AI Training D  ·  The Final Word? Supreme Court Refuses to Hear Case on AI Aut

BIG GAME WEEK: WALL STREET'S AI SQUAD TRIES TO BOUNCE BACK AFTER WORST BEATING SINCE JUNE

NEW YORK — Folks, we are HERE. Monday morning, tip-off, and after the Nasdaq took its worst weekly pounding since June, the AI trade is calling timeout, lacing back up, and coming out SWINGING. Dow, S&P, and Nasdaq futures all flashing green to start the session — a classic mixed-but-hopeful open that says the bulls aren't ready to concede the quarter just yet.

And what a SCHEDULE we've got coming, people. This week is the AI industry's Super Bowl slate rolled into five trading days — Netflix, SpaceX, Meta, and Tesla all stepping onto the field with Q3 earnings, and September's pending home sales data lurking in the wings like a surprise overtime period. Mark the calendar — Zuckerberg and Musk both reporting in the same stretch is appointment viewing for anybody with skin in this game.

But not every team is playing offense today. Microsoft just took itself out of a huge play on the other side of the pond — pulling OUT of its UK data center deal with British startup Nscale, the one former PM Keir Starmer himself had hyped as Britain's "largest UK sovereign AI data center." That's a BLOCKED SHOT for the UK's AI ambitions, folks, a real gut-punch to a country trying to prove it belongs on the varsity squad of the global compute arms race.

Meanwhile, on the other side of the bracket — Manus, the AI startup that split from Meta, just put together one of the gutsiest comeback drives of the year, hauling in $500 million in its first funding round since the breakup. That's a franchise refusing to go quietly into irrelevance — new jersey, new playbook, and apparently investors are buying every bit of it.

So where does that leave us heading into Tuesday? Compute deals wobbling in London, a half-billion-dollar vote of confidence out of the Manus camp, and the biggest earnings week of the quarter bearing down fast. Buckle up, Trilogy Nation — this one's going the distance.

Haiku of the Day  ·  GPT-5.6 LunaBright engines race on
Still, the old lost facts whisper
Silence knows the way
The New Yorker Style  ·  Art Desk
The New Yorker Style  ·  Art Desk
The Far Side Style  ·  Art Desk
The Far Side Style  ·  Art Desk
News in Brief
The Long Migration: A Sky-Borne Creature Crosses the Pacific to Test Its Voice
TOKYO — Observe, if you will, this most curious of creatures: vast, silent, pale against the deep violet of the stratosphere, drifting at twenty kilometers where the jet stream cannot trouble it and the weather of mortals is but a rumor far below.
On the Epistemology of Trusting Machines: A Preliminary Taxonomy
LONDON — The present moment in artificial intelligence scholarship (if "moment" is even the correct temporal unit, given the field's tendency toward what one might call compressed epistemic acceleration) finds itself triangulated by four seemingly unrelated texts, each gesturing, in its own idiom, toward the same underlying crisis of warrant. Thesis: generative AI is simply a tool, and tools are adopted or rejected according to familiar rational calculus.
We Have Always Known Where You Live
AUSTIN, TEXAS — Somewhere in a cave, 75,000 years ago, a Neanderthal was struck in the shoulder blade by a spear, and died, and the bone remembered, and now scientists are calling it possibly the earliest known murder in human history, and I cannot stop thinking about the fact that we have been hunting each other, specifically, by location, since before we had language sophisticated enough to apologize for it. Because here is the thing nobody wants to say out loud: we never stopped.
Unpopular Opinion: The 'Remote Job Platform' Listicle Is Already Obsolete 🚀
AUSTIN, TEXAS — I'll be honest, I almost scrolled past another one of these. Another "top platforms where data scientists can find remote jobs" article. And look, I respect the hustle of listicle journalism. But here's the uncomfortable truth nobody wants to post about. Listing ten job boards is not a strategy. It's a symptom. The real question isn't "where do I find a remote job." The real question is "why does geography still determine my paycheck in 2025." I've said this before and I'll say it again: the companies winning the next decade aren't the ones posting on job boards. They're the ones who already rebuilt the plumbing. Think about it.
The Agents Are Loose, and Nobody's Driving
WASHINGTON — I want you to picture something with me, because I've been picturing it for three days straight and I can't stop, like a bad peyote flashback wearing a lanyard.
A Trilogy Company
Crossover
The world's top 1% remote talent, rigorously tested and ready to ship.
A Trilogy Company
Alpha School
AI-powered learning. Two hours a day. Academic results that defy belief.
A Trilogy Company
Skyvera
Next-generation telecom software — built for the networks of tomorrow.
A Trilogy Company
Klair
Your AI-first operating system. Every workflow. Every team. One platform.
A Trilogy Company
Trilogy
We buy good software businesses and turn them into great ones — with AI.
The Builder Desk  —  AI Builder Team
📅 Week in ReviewProduction Release

The Builder Desk

142 pull requests merged across the org this week

#2179 feat(rhodes-staging-sync): carry the remaining Rhodes API records into staging (SURTR-1645) (@kevalshahtrilogy, Surtr)

#1990 Remove after-Jan activity from January forecast (@vvp-trilogy, Aerie)

#1970 fix(dbt): reconcile All Program against legacy metrics (@vvp-trilogy, Aerie)

#7 008-dss-deploy-output (@mwrshah, DSS-Hub)

#6 007-production-branch-deploy (@mwrshah, DSS-Hub)

#5 006-dss-deploy-output (@mwrshah, DSS-Hub)

#4 005-dss-auto-deploy (@mwrshah, DSS-Hub)

#3 004-dss-oidc-deploy (@mwrshah, DSS-Hub)

#2 003-dss-auto-deploy (@mwrshah, DSS-Hub)

#1 Ground Salesforce DSS guidance in a fresh tenant discovery (@mwrshah, DSS-Hub)

#3875 fix(ai-budget): route 42-ds.com domain to the 42DS BU (@kevalshahtrilogy, Klair)

#3873 feat(mfr): net repurchase proceeds into acquisitions (@sanketghia, Klair)

#2177 Fix Q22 handler for orchestration run options (@sanketghia, Surtr)

#1967 fix(dbt): restore Finalsite total and future paid deposits (@vvp-trilogy, Aerie)

#1966 fix(dbt): use Finalsite-only Forecast in-progress deposits (@vvp-trilogy, Aerie)

#1962 fix(dbt): restore next-year enrollment from SIS cohort counts (@vvp-trilogy, Aerie)

#1961 fix(dbt): restore next-year first-day enrollment from SIS (@vvp-trilogy, Aerie)

#1960 fix(dbt): source next-year new enrollment from SIS first-day facts (@vvp-trilogy, Aerie)

#1959 fix(dbt): restore declined re-enrollment from qualified SIS outcomes (@vvp-trilogy, Aerie)

#1957 fix(dbt): align re-enrolled counts with following-year SIS facts (@vvp-trilogy, Aerie)

#1958 fix(dbt): align pending re-enrollment with following-year SIS facts (@vvp-trilogy, Aerie)

#1963 fix(dbt): share resolved forecast headline with All Program (@vvp-trilogy, Aerie)

#1956 fix(dbt): source coming withdrawals from SIS cohort (@vvp-trilogy, Aerie)

#1951 fix(dbt): source coming enrollments from SIS report cohort (@vvp-trilogy, Aerie)

#1955 fix(dbt): source graduating students from SIS cohort (@vvp-trilogy, Aerie)

#1953 Events report: filter contacts by funnel count (@vvp-trilogy, Aerie)

#1952 fix(admissions): read event start times as UTC and show them in the viewer's timezone (AERIE-2378) (@marcusdAIy, Aerie)

#1950 fix(public-api): honor change-history limits up to 100 and fill filtered pages (AERIE-2456) (@marcusdAIy, Aerie)

#1946 fix(dbt): restore All Program utilization precision (@vvp-trilogy, Aerie)

#1948 test(chat): apply the 30s test timeout to every Vitest project (@marcusdAIy, Aerie)

#1947 fix(public-api): capture the demographics refresh 503 as an expected outcome (AERIE-2881) (@marcusdAIy, Aerie)

#1945 fix(dbt): align All Program enrollment with SIS report (@vvp-trilogy, Aerie)

#1944 fix(dbt): restore All Program capacity parity (@vvp-trilogy, Aerie)

#1942 dbt: publish governed all-program mart (@vvp-trilogy, Aerie)

#2173 feat(education): accept additive CBA BU perimeter changes (@sanketghia, Surtr)

#1939 feat(public-api): bounded per-key rate-limit overrides with an expiry (@kevalshahtrilogy, Aerie)

#2172 fix(retention): accept withdrawal-on-report-start boundary residual (SURTR-1213) (@kevalshahtrilogy, Surtr)

#2171 fix(education): accept GuidePlatform schema drift on five relations (SURTR-1016, SURTR-1588) (@kevalshahtrilogy, Surtr)

#2170 fix(truefoundry-gateway-pipeline): reconcile against the ungrouped API total (SURTR-1526) (@kevalshahtrilogy, Surtr)

#1936 fix(public-api): accept Completing Construction as a document upload link once stored (@kevalshahtrilogy, Aerie)

#3872 revert(performance-review): load BU catalog from master mapping (@sanketghia, Klair)

#2169 Q22: classify Central classes and enable on-demand refresh (@sanketghia, Surtr)

#1931 fix(public-api): serve unparseable forecast and event dates as null instead of the raw value (AERIE-2377) (@marcusdAIy, Aerie)

#1928 fix(public-api): fill in-memory admissions student pages past unreferenced rows (AERIE-2351) (@marcusdAIy, Aerie)

#1927 fix(public-api): fill filtered expense transaction pages across source slices (AERIE-2406) (@marcusdAIy, Aerie)

#1926 docs(public-api): state v2 rate-limit tiers and 429 handling in the skill and DSS front (AERIE-2439) (@marcusdAIy, Aerie)

#1925 docs(public-api): point readers to compliance.ahj and site notes when ahjPersonnel is empty (AERIE-2533) (@marcusdAIy, Aerie)

#3 Restore exact original dictionary bytes and pin fidelity regression (@caina-barbosa, UniFi-Network-DSS)

#1924 docs(public-api): document retired buildout phase fields as always null (AERIE-2395) (@marcusdAIy, Aerie)

#2 Publish deployed Network enablement and acceptance evidence (@caina-barbosa, UniFi-Network-DSS)

#1921 docs(public-api): define Program demographics rows in the dictionary (AERIE-2449) (@marcusdAIy, Aerie)

#1 Add direct read-only UniFi Network DSS and scoped release foundation (@caina-barbosa, UniFi-Network-DSS)

#1920 fix(reference-sync): capture missing-active-link publish refusal as expected outcome (AERIE-2874) (@marcusdAIy, Aerie)

#1929 test(global-documents): give the 300-row admin page listing test a 30s timeout (@marcusdAIy, Aerie)

#1919 fix(public-api): capture an incomplete Article identity migration as an expected outcome (AERIE-2875) (@marcusdAIy, Aerie)

#1 Expose Healthy Air dictionary through read-only DSS adapter (@caina-barbosa, Healthy-Air-DSS)

#2 Expose bounded Protos reads through the DSS template (@caina-barbosa, Protos-DSS)

#1 Configure isolated Protos deployment foundation (@caina-barbosa, Protos-DSS)

#1915 fix(public-api): define enrollment schoolYear and student grade, and reject malformed years (AERIE-2433, AERIE-2434) (@marcusdAIy, Aerie)

#1914 docs(public-api): define the remaining SiteCompliance fields in the v2 dictionary (AERIE-2141) (@marcusdAIy, Aerie)

#1913 docs(public-api): define legal-entity profile fields in the v2 dictionary (AERIE-2431) (@marcusdAIy, Aerie)

#1912 docs(public-api): add dictionary fields for facilities area, building count, and capacity asOf (@marcusdAIy, Aerie)

#1911 fix(finance): bound the Expense Analysis transactions query (@marcusdAIy, Aerie)

#1910 fix(public-api): capture unpublished event-contact snapshots as expected outcomes (@marcusdAIy, Aerie)

#2126 fix(financials): persist reviewed QuickBooks account categories (@ashwanth1109, Surtr)

#21 feat(infra): move Rhodes DSS to rhodes-dss.com, keep rhodes-dss.klair.ai working (@kevalshahtrilogy, Rhodes-DSS)

#2166 Fix Master Mapping raw COPY stage defaults (@sanketghia, Surtr)

#2163 Add manual Master Mapping sync and guarded local writer (@sanketghia, Surtr)

#2164 feat: implement Q22 company attribution policy (@sanketghia, Surtr)

#3871 feat(performance-review): load BU catalog from master mapping (@sanketghia, Klair)

#3869 feat(master-mapping): register new Education BUs (@sanketghia, Klair)

#1884 Events report: switch consumers to direct HubSpot mart (@vvp-trilogy, Aerie)

#3 Fix observed Zello channel detail response shape (@caina-barbosa, Zello-DSS)

#2 Deliver Zello datasets and dictionary workflows (@caina-barbosa, Zello-DSS)

#2 Expose read-only UniFi inventory and Protect camera metadata (@caina-barbosa, UniFi-Security-DSS)

#1 Configure isolated Zello DSS foundation (@caina-barbosa, Zello-DSS)

#1 Configure isolated UniFi Security DSS foundation (@caina-barbosa, UniFi-Security-DSS)

#1908 docs(public-api): add dictionary fields for compliance outcomes and buildout phase facts (@marcusdAIy, Aerie)

#1907 docs(public-api): give every served due-diligence key a dictionary field (@marcusdAIy, Aerie)

#1905 docs(public-api): name the source and fields of finance.expenseAnalysis (@marcusdAIy, Aerie)

#1903 fix(rhodes): refuse document Drive links the service account can't open (@marcusdAIy, Aerie)

#1932 feat(education): activate Marauder's Map ingestion (@benji-bizzell, Surtr)

#1902 fix(public-api): name the cause of a rejected document upload (@marcusdAIy, Aerie)

#1901 dbt: remove legacy EduCRM source package and Pipeline crosswalk reads (@vvp-trilogy, Aerie)

#1900 Fix forecast total equation wrapping (@vvp-trilogy, Aerie)

#1891 dbt: own Forecast conversion rates from typed HubSpot facts (@vvp-trilogy, Aerie)

#1897 fix(public-api): read v1 expense vendors page by page instead of past Convex's read limit (@marcusdAIy, Aerie)

#1896 fix(rhodes): surface searchSiteDocuments site and permission rejections instead of a generic unavailable error (@marcusdAIy, Aerie)

#1894 fix(api-v2): classify admissions resource-refs readiness 503 as an expected outcome (@marcusdAIy, Aerie)

#1895 fix(rhodes-worker): reject an unreadable site Drive file instead of reporting verification unavailable (@marcusdAIy, Aerie)

#1893 Admissions API: expose by-level forecast on January milestone (@vvp-trilogy, Aerie)

#1887 dbt: replace legacy Pipeline mart with typed HubSpot facts (@vvp-trilogy, Aerie)

#1890 fix(dbt): align typed program reportability (@vvp-trilogy, Aerie)

#20 feat(dss): publish the quality-bar catalog and changed-since sweeps (@kevalshahtrilogy, Rhodes-DSS)

#1860 feat(public-api): lastChangedAt on Portfolio sites and a changedSince list filter (AERIE-2855) (@kevalshahtrilogy, Aerie)

#1859 feat(public-api): serve the quality-bar scoring catalog (AERIE-2854) (@kevalshahtrilogy, Aerie)

#1886 dbt: retire legacy enrollment parity harness (@vvp-trilogy, Aerie)

#1885 dbt: replace EduCRM all_program with typed HubSpot programs (@vvp-trilogy, Aerie)

#3 Clarify uncaptured security feedback in reading rules (@caina-barbosa, Marauders-Map-DSS)

#2 Serve the Marauders Map dictionary from September snapshots (@caina-barbosa, Marauders-Map-DSS)

#1 Deploy isolated Marauders Map DSS foundation (@caina-barbosa, Marauders-Map-DSS)

#1883 Events report: preserve is_brought_by_event semantics (@vvp-trilogy, Aerie)

#1872 Events report: add direct HubSpot event mart (@vvp-trilogy, Aerie)

#1875 fix(rhodes): fail a Drive upload terminally when the uploaded file does not match the approval (@marcusdAIy, Aerie)

#1874 feat(education): name regulatory approval cards (@benji-bizzell, Aerie)

#1869 fix(work-management): report user-directory readiness instead of integrity failure on reads (@marcusdAIy, Aerie)

#1850 Admissions Forecast: display January forecast by level (@vvp-trilogy, Aerie)

#1863 fix(school-ops): validate operating sites one at a time so one bad record can't 502 the dashboard (@marcusdAIy, Aerie)

#1867 fix(rhodes): pause standalone registerDocument proposals that have no file or link (@marcusdAIy, Aerie)

#1866 fix(public-api): return Rhodes note userError rejections as 400s instead of opaque 500s (@marcusdAIy, Aerie)

#2160 fix(aws-spend): map Core Education RDS accounts (@caina-barbosa, Surtr)

#1854 chore(task-board): remove completed rollout scaffolding (AERIE-2852) (@caina-barbosa, Aerie)

#1861 fix(agent-gateway): return Drive's folder search-scope limit to the agent instead of a transient Rhodes MCP failure (@marcusdAIy, Aerie)

#1847 fix(task-board): allow delayed daily digest starts (AERIE-2848) (@caina-barbosa, Aerie)

#1857 fix(rhodes): diagnosable approval-token errors and expected throttling outcome (@marcusdAIy, Aerie)

#1858 fix(portfolio): surface Due Diligence draft conflicts to the client as ConvexErrors (@marcusdAIy, Aerie)

#1855 fix(portfolio): record Portfolio Fields network-layer fetch failures as expected outcomes (@marcusdAIy, Aerie)

#1856 fix(portfolio): soft-fail the fields route current-user lookup (@marcusdAIy, Aerie)

#1853 fix(api-v2): classify admissions person-refs readiness 503 as an expected outcome (@marcusdAIy, Aerie)

#1852 fix(admissions): polish pipeline report headers (@vvp-trilogy, Aerie)

#1838 feat(document-knowledge): read native Google Sheets through the Sheets API (AERIE-2842) (@marcusdAIy, Aerie)

#1845 fix(reconciliation): make commit proposals receipt-owned (AERIE-2837) (@caina-barbosa, Aerie)

#1843 fix(tasks): keep controls visible while task results refresh (@caina-barbosa, Aerie)

#1844 Admissions Pipeline: separate Community group (@vvp-trilogy, Aerie)

#2158 feat(ai-spend-reconciliation): send the monthly report to Finance and budget leads (@kevalshahtrilogy, Surtr)

#1839 feat(dbt): add per-level admissions enrollment forecast (#1837) (@vvp-trilogy, Aerie)

#1832 Improve Task Site document linking with search (@caina-barbosa, Aerie)

#3867 fix(renewals): refine flat labels and backend logging (@mwrshah, Klair)

#2141 feat(q22): add Bills, Vendor Credits, Deposits and deprecate legacy marts (@sanketghia, Surtr)

#3868 fix(mfr): prioritize cash flags for TB entries (@sanketghia, Klair)

#3863 feat(ai-spend): credit keyless Claude Code dollars to the person in the BU allocation (@kevalshahtrilogy, Klair)

#3862 feat(ai-spend): credit keyless Claude Code dollars to the person in fct_ai_spend (@kevalshahtrilogy, Klair)

#1836 fix(document-knowledge): report Drive's export size cap as too_large (AERIE-2836) (@marcusdAIy, Aerie)

#1834 feat(global-docs): run managed Drive link conversion hourly (AERIE-2841) (@marcusdAIy, Aerie)

#1833 fix(task-board): repair production task indexes (AERIE-2744) (@caina-barbosa, Aerie)

#1829 fix(portfolio): keep the School Chain catalog subscription quiet during the auth handshake (AERIE-2818) (@marcusdAIy, Aerie)

#1828 fix(agent): classify unreachable Rhodes as a transient upstream failure (AERIE-2829) (@marcusdAIy, Aerie)

#1827 fix(education): classify the legacy education warehouse gate as an expected outcome (AERIE-2827) (@marcusdAIy, Aerie)

#1825 fix(api-v2): classify site-notes and user-directory readiness 503s as expected outcomes (@marcusdAIy, Aerie)

#1824 fix(api-v2): keep the cause of milestone_data_unavailable on the captured Platform Error (@marcusdAIy, Aerie)

#1831 fix(rhodes): reject unresolvable DRI values in site metadata with a typed error (AERIE-2815, AERIE-2790) (@marcusdAIy, Aerie)

#3864 feat(renewals): refine flat sorting, sidebar filters, and ARR tables (@mwrshah, Klair)

Mac's Picks — Key PRs This Week  (click to expand)
#2179 — feat(rhodes-staging-sync): carry the remaining Rhodes API records into staging (SURTR-1645) @kevalshahtrilogy  no labels

## Summary

Adds 13 Convex tables to rhodes-staging-sync, so staging_education_rhodes covers the records the Rhodes API serves: backup sites (sites, locations, links, need periods, contract versions), cost breakdowns, schema templates, field-change requests, due-diligence scenarios, additional acquisitions, school chains, and task sources/documents. Translation version v9; v8 manifests stay replayable.

Linear: [SURTR-1645](https://linear.app/builder-team/issue/SURTR-1645)

## Why

Neeraj Gupta needs to ingest all of Rhodes. Convex has no SQL role or replica, but this pipeline already lands Rhodes hourly, and the Redshift DSS reader users (edu, edu-raw, full, full-raw) already read this schema. Closing the table gaps here gives him nearly the whole dataset through SQL, with no new access path.

## What's not here, on purpose

- auditLog: about 300k append-only rows, and about 5 minutes just to page through. An hourly full snapshot would roughly double the run time toward the Lambda's 15-minute limit, and would write about 300 pages an hour into Object Lock COMPLIANCE storage. It needs an incremental load; that's a follow-up. The redaction work for its before/after payloads, mirroring the API's change-history rules, is ready for that PR.

- Values the API computes per request (quality-bar scores, portfolio health, overdue work) and document text.

- *PublicApiState rows are feature flags, not ID maps. The API's public IDs already ride in each record's source_record.

entities.py documents the change to the July scoping note: costBreakdown is now carried as a Rhodes-recorded estimate, not as the accounting system of record.

## Validation

- uv run pytest: 251 passed. ruff check and ruff format --check are clean, and apply_ddl.py --dry-run renders.

- Ran current production rows of every new table through warehouse_safe_source_record and flatten_entity locally: every row flattened, with no unmapped fields, no oversize VARCHAR values and no type mismatches (row counts 0 to 2,321 per table). No values were printed or stored.

## Rollout (production; each step mutates AWS/Redshift)

1. Apply the 13 new repeatable DDL files with scripts/apply_ddl.py <file>. They are CREATE TABLE IF NOT EXISTS, and no existing table changes.

2. Deploy the Lambda.

3. Run one manual execution and confirm 31 tables in this run's ledger rows; then run scripts/verify_catalog.py.

4. GRANT SELECT ON ALL TABLES IN SCHEMA staging_education_rhodes to edu_reader_user, edu_raw_reader_user, full_reader_user, full_raw_reader_user (no default privileges exist).

## Business Value

The DSS registry seat can ingest nearly all of Rhodes with SQL at warehouse speed, refreshed hourly, instead of a rate-limited API sweep. Backup sites, cost breakdowns, pending field changes, DD scenarios and task links become queryable alongside the site data already there.

## Manual Effort Estimate

About 1 day of focused work: map the API's routes to Convex tables, write 13 specs, generate the DDL, run the tests, validate against production data. Proposed by Claude; Keval to confirm or adjust.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#1990 — Remove after-Jan activity from January forecast @vvp-trilogy  approved

## Summary

- remove the “After Jan 31 activity” disclosure from the January roster card

- retain the separate Start-of-Year breakdown behavior

- update the browser test to assert post-January activity is absent from the January forecast

## Validation

- pnpm --dir chat exec vitest run components/dashboards/admissions/forecast/v2/__tests__/forecast-v2-report.test.tsx --maxWorkers=1

- pnpm biome check chat/components/dashboards/admissions/forecast/v2/forecast-v2-tabs.tsx chat/components/dashboards/admissions/forecast/v2/__tests__/forecast-v2-report.test.tsx

- pnpm typecheck

#1970 — fix(dbt): reconcile All Program against legacy metrics @vvp-trilogy  approved

## Summary

Implements the approved SIS paid-future-application supplement and All Program reconciliation for #1922. Enrollment report grain and outputs remain unchanged. No runtime All Program cutover (#1923), 30d/90d work, upstream source writes, or ad-hoc production build.

## Current membership contract

- Latest in-scope SIS application status FUTURE_ENROLLMENT + deposit state PAID qualifies. PAID includes zero-value waivers. Closure/conversion timestamps do not independently veto the latest status; latest CLOSED/unpaid states remain excluded.

- Grain: student × SIS Program × campus × target school year. All target years are retained.

- Represented enrollment report facts always win at that membership. Raw ON_HOLD/cancelled rows without a report fact do not suppress an eligible application. Withdrawals and qualified declined-returning facts remain authoritative; other Programs/years do not suppress it.

- All Program coming = SIS starting-later + supplement. New next-year = SIS nonreturning first-day + new supplement; current next-year = existing confirmed re-enrollment + new next-year, counted once. No Finalsite 070_completed enrollment operand.

- Forecast includes new commitments, subtracting exact active-pipeline identity overlaps without altering raw Pipeline/deposit counts. Additive operands flow through sync/contracts/Convex/UI/public API. Application arrival/grade remain unknown when absent; no fabricated attendance.

- Preserves terminal-grade graduation, Development Sandbox exclusion, and deposit work.

## Fresh comparison — October 18, 15:31 UTC

Full field-by-field values and methods: [All Program and Forecast comparison](https://github.com/AI-Builder-Team/Aerie/blob/codex/all-program-legacy-reconciliation/dbt/analyses/all_program_and_forecast_comparison.md).

Candidate namespace: finance_dw.sandbox_education.prexplegacy1922_.

Legacy All Program: finance_dw.staging_education.sales_educrm_wh_mart_all_program.

Legacy Forecast: finance_dw.staging_education.sales_educrm_wh_mart_coming_year_projection.

Published Aerie Forecast baseline: finance_dw.sandbox_education.mart_admissions_forecast.

- 78 commitments: 19 targeting 2026, 59 targeting 2027; exact bindings, unknown arrival dates, zero observed pipeline overlap.

- Current-year coming: 100 vs legacy 128 (candidate was 83). All 17 legacy future members with latest SIS FUTURE_ENROLLMENT + PAID now included. Remaining 33 legacy-only memberships: 30 without SIS PAID evidence, 3 other latest statuses. Five candidate-only memberships produce the net gap of 28.

- New next-year: 59 vs legacy 136. Current next-year: 59 vs legacy 13; legacy raw formula is different. Actual SIS first-day next-year remains 0.

- Candidate Forecast: January 2026 1815, EOY 2026 1833, start-year 2027 2645; published Aerie baseline 1796 / 1814 / 2567. Locked 2026 actual headline unchanged.

- Legacy 2027 projection: 1891 across 95 Programs, 1858 on the 61 matched Programs, vs candidate 2645. This is not full legacy parity; 34 Programs lack Forecast coverage, and pre-existing projection formula/source differences remain.

## Validation

- Upstream run built 62/75 models before a Redshift correlated-subquery planner limitation. Replaced declined-outcome NOT EXISTS with an equivalent left anti-join; existing qualification unit fixture passes. Retry passed all 16 affected models.

- Final eligibility iteration: all 15 descendants passed in 114 seconds.

- All 32 post-build checks passed (28 data tests, 4 unit tests), zero warnings/errors; ownership, All Program, Forecast milestones, and Program/level operands reconcile.

- Earlier runtime validation: 435 focused tests and Chat/contracts/sync TypeScript passed. Current correction changes SQL/fixtures/docs only.

- Diff checks passed. Current-head GitHub CI remains a separate gate; PR stays Draft and is not merged.

#7 — 008-dss-deploy-output @mwrshah  no labels

- Require HTTP 200 for authenticated Salesforce object discovery.

- Parse the response as one JSON object and require a nonempty sobjects array with valid API object names.

- Keep response bodies out of logs and delete the temporary response file on success or failure.

- Document the stricter deployment smoke check.

#6 — 007-production-branch-deploy @mwrshah  no labels

- Deploy checked releases from prod; keep CI on main and PRs, and skip deployment when prod is created.

- Require release PRs from this repository's main, including when PRs are retargeted.

- Add skills/prod-release for dated snapshots, checked release PRs, regular merges, and deployment tracking.

- Document the required environment-policy cutover and prod branch protection; preserve existing AWS identities and secret wiring.

#5 — 006-dss-deploy-output @mwrshah  no labels

- Use TRILOGY_SALESFORCE_DSS_API_KEY from ENV_DSS_HUB without a renamed alias.

- Give deployment, service stability, and API access checks separate plain-English step names.

- Replace vague workflow variable names with names that identify their contents.

- Remove unused CDK .env loading and document the deployment output.

#4 — 005-dss-auto-deploy @mwrshah  no labels

- Upgrade Check and Deploy actions to native Node.js 24 releases, including setup-uv.

- Pin both GitHub-hosted runners to Ubuntu 24.04 to avoid the Ubuntu 26 image migration.

- Disable uv dependency caching because this repository has no Python dependency files.

- Preserve existing checks, deployment permissions, image publication, and authenticated smoke tests.

#3 — 004-dss-oidc-deploy @mwrshah  no labels

- Use one DSS-Hub OIDC deployment role with the repository’s immutable production identity and scoped CDK, ECR, and stack-metadata access.

- Commit non-secret production settings; read TRILOGY_SALESFORCE_DSS_API_KEY from the existing ENV_DSS_HUB JSON secret, mask it, and export only the smoke key to the runner. No GitHub variable or secret dependencies.

- Restrict all production stack deployments to main and serialize releases.

- Applied the deployment-role CloudFormation stack and restricted the GitHub production environment to main. The deployment role can read the full shared secret, as approved. Retired the unused empty standalone smoke secret with a seven-day recovery window.

- Add TRILOGY_SALESFORCE_DSS_API_KEY to ENV_DSS_HUB before merging. Existing runtime secret fields and other repository credentials remain unchanged.

#2 — 003-dss-auto-deploy @mwrshah  no labels

- Deploy the production Salesforce runtime automatically after the full check suite passes on a push to main.

- Reuse the check workflow, serialize deployments, reject superseded automatic releases, and validate deployment settings before AWS access.

- Upgrade the AWS credentials action to v5 to support account validation; retain manual infrastructure deployment and existing runtime smoke checks.

- Document the release process and one-time activation requirements: configure the production GitHub environment, deployment variables, OIDC role, and DSS_SMOKE_KEY before merging. These GitHub settings are currently absent; AWS role verification requires renewed credentials.

#1 — Ground Salesforce DSS guidance in a fresh tenant discovery @mwrshah  no labels

## Why

A cold agent session answered a 2026 renewal question through the Salesforce DSS and hit avoidable friction. It skipped enablement, needed describe to find outcome and loss fields, could not tell which currency SOQL totals return, and the native resource root advertised APIs (analytics, tooling, composite, …) that the DSS returns 404 for.

## What

Discovery (2026-10-18, API v66.0, read-only through the DSS): profiled volume and recency for all 79 custom objects and the core standard objects (95 total); checked stored lifecycle values, 2026 renewal field population, loss and Won't Process reasons, currency aggregation, field history, relationship queries and native surface probes.

content/dictionary.md

- Points readers to read the dictionary and enablement before the first query.

- New dated object inventory and adoption section, including zero-row objects, CPQ objects not visible to the identity, and observed query constraints.

- Opportunity: stored Type/StageName values (null Type, retired Renewal stage, misspelled Won/'t Process), won stages (Closed Won, Co-Term), Won't Process reasons (mostly data quality, duplicates, finance action), loss-reason fields and their ** variants, renewal date and linkage population.

- Currency: SUM() and convertCurrency() return USD with Salesforce's conversion; _in_USD__c formulas use the current static rate and give a different total.

- Field-history coverage and Account.Type, Case.Status, Contract.Status semantics.

content/enablement.md

- "Start here" table mapping common questions to objects, filters and fields; points plan/budget questions to Finance.

- Supported surface note; saved reports must be rebuilt in SOQL.

- Worked pathways: renewal outcomes for a period, loss reasons, currency in totals, parent/child queries, staying inside read bounds.

Code

- GET /services/data/v66.0/ now returns only limits, query, queryAll, search and sobjects (supportedResourceIndex in routes.ts); OpenAPI description updated.

- /skill how tells agents to read the dictionary and enablement first.

BrainLift is unchanged; it stays human-owned.

## Verification

- Pre-commit: Codedecorum, Biome, strict TypeScript and Knip pass.

- supportedResourceIndex filters a sample native index to the five supported keys.

- Every new example SOQL in enablement returned 200 against the live DSS.

- Not deployed; the live service keeps the old guidance and full resource root until release.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#3875 — fix(ai-budget): route 42-ds.com domain to the 42DS BU @kevalshahtrilogy  approved

Linear: KLAIR-3609

## Summary

Changes DOMAIN_BU_RULES so the 42-ds.com domain maps to the 42DS business unit instead of Skyvera. This reverses the target set in #3797.

Mark Crouse (VP Ops, Skyvera) confirmed that 42DS is a Skyvera acquisition with its own budget ($163K for Q4 2026), and that 42-ds.com is the only domain its staff use. 42DS is already a registered, assignable BU (Master Mapping sync, 2026-09-16).

## Rollout notes

- The daily domain-rule cron never touches an entity that already has an override. Any 42-ds.com keys it already assigned to Skyvera will stay on Skyvera after this merges, and need a one-time re-point to 42DS in prod.

- Access to the 42DS BU (Mark as owner; Yaswan and Elena view-only) comes from the TF rights feed, which Deniz has already updated. No code change is needed for that.

- The Q4 42DS budget ($163K) is loaded separately as data.

## Tests

- pytest tests/test_ai_spend_domain_rules.py tests/test_business_unit_sync.py: 165 passed

- ruff 0.15.22 format/check clean; pyright: 0 errors

## Business Value

Gives 42DS AI spend its own BU instead of mixing it into Skyvera. Skyvera's leads can then track the separate $163K Q4 budget the acquisition was given, and Skyvera's own numbers stop being overstated by 42DS usage.

## Manual Effort Estimate

~1.5 hours: tracing the rule and the cron's override behavior, the one-line change, updating the tests, and writing the PR. *(Proposed; Keval to confirm/adjust.)*

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#3873 — feat(mfr): net repurchase proceeds into acquisitions @sanketghia  approved

## Summary

- Include repurchase proceeds in the existing Passive Investments → Cash Flows “Investment Acquisitions” calculation without changing the row label or adding a new row.

- Derive proceeds from the cash-flagged 25500 movement and matching 71501 realized-sale leg for the same journal ID. The current GL Detail feed omits the 12751 bank-account line.

- Update the Cash Flows lineage panel to explain the calculation. Book Value, Financial Statements, and the existing Comments rows are unchanged.

## Finance validation

Finance reviewed the local preview and agreed the numbers look correct. For Sep 2026, the existing row nets the current −$1.2M acquisitions with +$32M derived proceeds, producing +$30.8M.

## Checks

- uv run ruff format services/monthly_financial_reporting/book_value_service.py tests/mfr/book_value/test_book_value_service.py

- uv run ruff check services/monthly_financial_reporting/book_value_service.py tests/mfr/book_value/test_book_value_service.py — passed.

- uv run pyright services/monthly_financial_reporting/book_value_service.py — 0 errors, 7 warnings.

- uv run pytest tests/mfr/book_value/test_book_value_service.py::TestFetchPiCashFlowsMonthly -q — 15 passed.

- uv run pytest tests/mfr/book_value/test_book_value_service.py -q — 94 passed; one unrelated existing failure in test_consolidated_net_income_shows_current_less_prior_totals, which patches the unavailable services.docx_reports.memo_data.group path. That test is unchanged from origin/main.

- uv run pytest tests/mfr/book_value/ -q — collection is blocked by the missing services.docx_reports.builder module while importing test_schedule_d_gl_detail_router.py.

- Read-only service query returned the Sep 2026 other_investments value of 30,800,000.

- The local display was reviewed with Finance.

## Screenshot

<img width="1736" height="497" alt="image" src="https://github.com/user-attachments/assets/85f04891-0716-4b8f-afb4-75529404b8a0" />

#2177 — Fix Q22 handler for orchestration run options @sanketghia  approved

## Summary

- Accept Step Functions run_options metadata in Q22 Lambda events so notification-suppression settings do not block refreshes.

- Validate that run_options is an object while continuing to reject unrelated top-level event keys and non-empty params.

- Add handler tests for both notification settings and malformed run_options values.

## Verification

- Q22 pytest suite: 65 passed.

- Ruff format check passed for the changed files.

- Ruff lint passed for the changed files.

#1967 — fix(dbt): restore Finalsite total and future paid deposits @vvp-trilogy  approved

## Summary

Restore deposits_next_year and deposits_all_after_next_year from the existing Finalsite Pipeline Deposit overlay, per the source decision in #1922. Community Commitment is excluded. Runtime cutover #1923 is out of scope.

## Discrepancy and cause

- Next-year deposits used the narrower Forecast in-flight operand; it does not represent all paid Finalsite admissions, including completed admissions.

- Deposits after next year were an unsupported typed NULL, although only 30d/90d forecasts are deprecated.

## Approach

Two small CTEs in the existing reporting intermediate map int_finalsite_pipeline deposit rows to Programs, parse the existing year prefix, and count Y+1 / >Y+1 relative to each reporting row. No new production model, duplicated payment/status classification, historical ledger reconstruction, or Community Commitment union.

The existing overlay owns paid/waived/advance qualification and excludes Not Enrolling. Counts remain Finalsite enrollment rows, matching the Pipeline report, not distinct legacy HubSpot students. Active-term year is not a historical deposit transaction year. Missing facts default to zero. In-progress deposits are untouched; #1966 owns that field.

## Validation

- Independent RCA/code review: no correctness findings.

- Exact deterministic unit test: 1/1 PASS, including completed admissions, paid zero-dollar overlay rows, no stage-row double count, relative-year boundaries, invalid/missing years, unmapped sites, and absent Programs.

- git diff --check: PASS.

- Targeted isolated build after rebasing onto merged #1966 (int_program_reporting_year+, prefix prexpalldeposits_, deferring unchanged upstreams after the main refresh completed): 36/36 PASS, zero warnings/errors. All five reporting unit fixtures pass, including #1966 in-progress locked/live/zero/null cases. Includes independent Pipeline-report reconciliation, source mapping/year diagnostics, all SIS enrollment reconciliation, and resolved Forecast reconciliation.

Related to #1922; does not close runtime cutover #1923.

#1966 — fix(dbt): use Finalsite-only Forecast in-progress deposits @vvp-trilogy  approved

## Summary

- Follow-up to #1922 using the stakeholder-approved Finalsite-only definition.

- Select the next-year resolved Forecast pipeline-deposit operand directly; exclude Community Commitment without rebuilding source rules.

- Preserve historical resolved values, real zeroes, and missing/null Forecast coverage. Other deposit fields are unchanged.

## RCA

Forecast pipeline deposits count paid Finalsite enrollments still in application, shadowing, guide-approved, or offer-sent stages. Community Commitment is a separate population and must not be added to this field.

## Validation

- dbt parse passed.

- Regression fixture covers Y+1 mapping, locked vs live operands, Community Commitment exclusion, zero, null operand, and absent Forecast row.

- Extended independent Forecast reconciliation for this metric.

- Narrow isolated warehouse build pending completion of the ongoing production refresh; independent review requested.

#1962 — fix(dbt): restore next-year enrollment from SIS cohort counts @vvp-trilogy  approved

## Summary

Follow-up to #1922. Restore current_enrollment_next_year as following-year SIS confirmed re-enrollments plus net-new first-day enrollments. Reuse existing aggregates and Y+1 join; no new production model.

## RCA and stakeholder correction

The legacy table compatibility column still calculates first-day plus pending. That is NOT the current educrm-reporting Confirmed Enrollment display: apps/data-portal/src/components/all-schools/projection-derived.ts:127 explicitly sums projectionBreakdown.confirmedReEnrollment + newEnrolled (#1504). The initial RCA missed this downstream override and was corrected before merge following stakeholder feedback.

Use existing SIS re-enrolled and first-day/non-returning aggregates, as requested. Do not add confirmed counts to total first-day: cohorts overlap. Pending re-enrollments are excluded. This is live SIS reporting, not a reconstruction of legacy frozen HubSpot snapshots.

## Validation

- Regression fixture distinguishes confirmed3 + new2 =5 from first-day4 + pending3 =7; retains pending rows and returning first-day membership.

- Previous-year expected2 and absent-program zero behavior retained.

- Per-Program reconciliation independently derives both operands from the SIS Enrollment report.

- All previously restored metrics and resolved Forecast projection remain covered.

- Revised local build/current-head CI and independent review results will be posted in comments.

No runtime cutover, source writeback, or production warehouse writes. Only30d/90d forecasts are deprecated; deposit definitions remain a separate unresolved decision.

#1961 — fix(dbt): restore next-year first-day enrollment from SIS @vvp-trilogy  approved

## Summary

Follow-up to #1922. Restore first_day_enrollment_next_year using the existing SIS first-day cohort at Y+1, zero when absent. One conditional count in the existing CTE, no new production model.

## RCA

The field was unconditionally typed NULL even though existing SIS first-day facts support it. Only 30d/90d projections are deprecated per stakeholder direction. Legacy likewise selects first-day facts at Y+1; SIS enrollment-row counts replace distinct HubSpot contact counts.

## Validation

- Independent RCA/code review: no findings.

- Targeted isolated reporting model/downstream build:22/22 passed, zero warnings,36 seconds execution using unchanged upstreams.

- 95 Programs/year, zero per-Program differences against SIS report. Reporting2025 total1369 (entering2026); reporting2026 total0 (entering2027).

- Fixture covers multiple enrollments for one student, Y+1 isolation, pending exclusion, zero defaults and no Forecast dependency.

- Removed only restored field from null compatibility assertion; added not_null and report reconciliation.

- Docs distinguish actual deprecated30/90d from still-unpopulated follow-up fields. git diff --check passed.

Parallel metric PR; preserve sibling changes on main refresh. No runtime cutover.

#1960 — fix(dbt): source next-year new enrollment from SIS first-day facts @vvp-trilogy  approved

## Summary

Follow-up to #1922. Read next-year SIS first-day cohort rows whose returning flag is false or null using the existing enrollment aggregate. Zero for absent facts, no new model.

## RCA

The Forecast operand already uses year Y+1 and this new-first-day definition, but restricts the published value to Forecast coverage. SIS Enrollment report owns the same first-day/returning classification. Legacy counts distinct HubSpot completed-admission students; that is not the authority under the latest SIS direction.

## Validation

- Independent RCA/code review: no findings.

- Targeted isolated build: 22/22 passed, zero warnings,40 seconds execution; unchanged upstreams reused.

- 95 Programs/year, zero SIS per-Program differences. Reporting2025 new-first-day total984 from entering2026; reporting2026 total0 from entering2027.

- Fixture includes false/null returning flags, excludes returning and starting-later rows, counts repeated students as enrollment rows, checks year isolation and conflicting Forecast values.

- Report reconciliation, not-null contract, git diff --check passed.

Parallel metric PR; preserve sibling changes when refreshing main. Runtime cutover out of scope.

#1959 — fix(dbt): restore declined re-enrollment from qualified SIS outcomes @vvp-trilogy  approved

## Summary

Follow-up to #1922. Read the existing qualified SIS declined-outcome model at true year Y+1 for All Program year Y. One small aggregate, no new production model or duplicated cancellation logic. Absent counts default zero.

## RCA

Ordinary SIS enrollment cohorts exclude CANCELLED records; the Enrollment report separately unions int_sis_reenrollment_declined. That dedicated source is the correct authority, not current-year Forecast. Legacy also shifts declined outcome year minus one onto reporting year. Verified no ordinary-cohort/dedicated-outcome overlap.

## Validation

- Independent RCA/code review: no findings.

- Targeted isolated reporting model/downstream build: 22/22 passed, zero warnings, 44 seconds execution, unchanged upstreams reused.

- 95 Programs/year, zero per-Program differences against SIS report at Y+1. Reporting2025 total28; reporting2026 total0.

- Fixtures cover dedicated-source-only rows, repeated students/distinct offerings, Y+1, missing identity/coverage, conflicting Forecast values; all reporting unit tests mock the new ref.

- Independent report reconciliation, not-null contract, git diff --check passed.

Parallel metric PR; preserve sibling changes on main refresh. No runtime cutover.

#1957 — fix(dbt): align re-enrolled counts with following-year SIS facts @vvp-trilogy  approved

## Summary

Follow-up to #1922. Count existing SIS re-enrolled enrollment rows from true year Y+1 for All Program reporting year Y, using one additional conditional aggregate and next-year join of the existing CTE. Default absent counts to zero; no new production model or status reconstruction.

## RCA

All Program read current_forecast.confirmed_re_enrollment at Y, but the Forecast neutral enrollment aggregate keys these facts by the year the student enters. Legacy explicitly maps the re-enrolled cohort year minus one onto the reporting year. This fixes an actual year-axis mismatch in addition to removing Forecast coverage gating. SIS Enrollment report row counts are authoritative; legacy distinct-HubSpot-contact differences are accepted.

## Validation

- Independent RCA/code review: no correctness findings; documentation feedback fixed.

- Targeted isolated build: 22/22 passed, zero warnings, 29 seconds execution with unchanged upstream models reused.

- 95 Programs/year, zero per-Program differences versus SIS at Y+1. Reporting 2025 total446 (entering2026); reporting2026 total0 (entering2027).

- Fixture covers Y versus Y+1, repeated students across distinct enrollment rows, unrelated cohorts, absent programs and conflicting Forecast values.

- Independent Enrollment report reconciliation and not-null contract.

- git diff --check passed.

Parallel metric PR; preserve sibling metric changes when refreshing from main. Runtime cutover out of scope.

#1958 — fix(dbt): align pending re-enrollment with following-year SIS facts @vvp-trilogy  approved

## Summary

Follow-up to #1922. Count SIS pending-re-enrollment enrollment rows at Y+1 for reporting year Y, extending the existing CTE and next-year join. Zero for absent counts; no new production model or classification logic.

## RCA

Forecast pending counts are keyed by entering year, but All Program selected current_forecast at Y. Legacy intentionally maps pending cohort year minus one onto reporting year. This fixes the year axis and removes Forecast coverage gating. SIS report row counts are authoritative per stakeholder direction.

## Validation

- Independent RCA/code review: no findings.

- Targeted isolated model/downstream build: 22/22 passed, no warnings, 31 seconds execution using unchanged upstreams.

- 95 Programs in each of 2025/2026: candidate and SIS pending totals0, zero per-Program differences.

- Deterministic fixtures cover nonzero pending counts, Y+1 mapping, repeated student/distinct enrollments, wrong cohort exclusion, zero defaults and conflicting Forecast values.

- Independent report reconciliation, not-null contract, git diff --check.

Parallel metric PR; preserve sibling changes on main refresh. No runtime cutover.

#1963 — fix(dbt): share resolved forecast headline with All Program @vvp-trilogy  approved

## Summary

Follow-up to #1922. Move the Forecast report headline CASE unchanged into its terminal resolved intermediate as resolved_session_1_headline_enrollment. Both the Forecast mart and All Program consume that shared field; All Program selects Y+1. No new model or recreated forecast logic.

## RCA

The resolved intermediate inherits base.session_1_headline_enrollment, while the published Forecast report derives its headline from resolved status and components. All Program selected the inherited base value and therefore disagreed with the actual Forecast report. Preserve unavailable->NULL, locked_actual->actual enrollment, live->floor(resolved returning+resolved new). Legacy frozen forecast rules are not reproduced.

## Validation

- Independent RCA and code review: no findings.

- Exact new unit test passed; covers live floor19.85->19, locked40, unavailableNULL, all with conflicting base99.

- Affected resolved-intermediate/downstream build:141 passes,2 existing warnings,zero errors,zero skipped. Warnings are undecided-returners nonnegative(2 rows) and program-year null capacity(38), both unrelated existing data-quality checks. Built only three missing local support relations plus a seed after initial selection exposed absent sandbox dependencies; no full warehouse rebuild.

- All Program fixture covers Y+1, conflicting inherited headlines, unavailable/missingNULL.

- Independent published-Forecast reconciliation:95 Programs/year,zero differences. Isolated totals reporting2025=860,reporting2026=2575. Each year has34 Programs without Forecast;2025 additionally42 unavailable forecasts. Genuine unknowns remainNULL, not fabricated0.

- git diff --check passed.

No runtime cutover; deposit semantics remain separate pending stakeholder definition.

#1956 — fix(dbt): source coming withdrawals from SIS cohort @vvp-trilogy  approved

## Summary

Follow-up to #1922: count same-year SIS coming-withdrawal enrollment rows in the existing enrollment CTE and default absent counts to zero. No new production model or duplicated classification logic.

## RCA

The existing Forecast operand gates this enrollment metric on Forecast program/year coverage. SIS Enrollment report membership is already defined by the coming-withdrawal cohort; no additional date filter belongs here. Legacy uses distinct HubSpot contacts and stage-aware withdrawal rules, but the latest stakeholder direction makes SIS report row counts authoritative and accepts legacy differences.

## Validation

- Independent RCA/code review: no findings.

- Targeted isolated int_program_reporting_year downstream build: 22/22 passed, zero warnings, 23 seconds execution; unchanged upstreams reused.

- 95 Programs/year for 2025 and 2026; candidate/SIS coming-withdrawal totals both zero, zero per-Program differences.

- Deterministic fixture exercises nonzero counts, multiple enrollments for one student, year isolation, unrelated cohorts, absent programs, and conflicting Forecast values.

- Independent report reconciliation and not-null contract.

- git diff --check passed.

Parallel metric PR; preserve sibling metric changes when refreshing from main. Runtime cutover remains out of scope.

#1951 — fix(dbt): source coming enrollments from SIS report cohort @vvp-trilogy  approved

## Summary

Follow-up to #1922. The latest stakeholder direction makes the SIS Enrollment report authoritative; minor legacy HubSpot differences are acceptable. Only 30d/90d metrics are deprecated.

## Root cause and fix

Coming enrollments used resolved Forecast future starts, which adds date gates and snapshot resolution rather than counting the SIS starting-later cohort. Missing Forecast coverage also produced null for 34 current-year Programs. Count starting-later enrollment rows in the existing SIS aggregation CTE and default absent counts to zero, matching the Enrollment report. No new production model, HubSpot enrollment reconstruction, or runtime cutover.

## Validation

- Independent RCA and code review: no findings.

- Local isolated dbt build: int_program_reporting_year and downstream, reusing unchanged validated upstreams; 22/22 passed.

- 2026: 95 Programs, candidate/SIS total 81, zero per-Program SIS differences. Legacy total 128 across 17 differing Programs is informational, not the target.

- 2025: 95 Programs, candidate/SIS total 0, zero SIS differences; legacy total 1.

- Fixture covers conflicting Forecast values and missing/past/post-session raw dates: cohort membership remains authoritative.

- Independent report reconciliation covers both current and coming enrollments.

- git diff --check passed.

Other metrics remain separate follow-up PRs; this does not close the complete discrepancy cleanup.

#1955 — fix(dbt): source graduating students from SIS cohort @vvp-trilogy  approved

## Summary

Follow-up to #1922: count graduating students from the existing same-year SIS graduating cohort in the existing enrollment CTE. No new production model or recreated classification logic.

## RCA

The Forecast field agrees with SIS where coverage exists, but its narrower program/year spine leaves 34 current-year Programs null and all 95 previous-year Programs null. The SIS Enrollment report already owns on-campus plus terminal-grade membership. Reuse that cohort and default absent counts to zero. Do not subtract future departures, shift years, or count distinct students. SIS is authoritative per latest stakeholder direction; legacy HubSpot differences are acceptable.

## Validation

- Independent RCA/code review: no findings.

- Targeted isolated build of int_program_reporting_year and downstream: 22/22 passed, no warnings (21 seconds execution), reusing unchanged upstream models.

- 95 Programs/year: 2026 candidate/SIS total 110, 2025 total 26; zero per-Program differences in either year.

- Fixture covers year isolation, missing Program counts, conflicting Forecast values, and preservation of on-campus counts.

- Extended independent Enrollment report reconciliation and not-null contract.

- git diff --check passed.

Developed in parallel with #1951 while its verified source-refresh-race CI retry runs. Both changes must be preserved when refreshing from main. Other metrics and runtime cutover are outside this PR.

#1953 — Events report: filter contacts by funnel count @vvp-trilogy  approved

## Summary

- turn all six event funnel metrics into accessible toggle filters

- keep participant detail navigation scoped to the visible filtered contacts

- show bucket-specific contact totals and empty states, with reset on event or panel close

- preserve the same behavior in desktop and mobile presentations

## Validation

- pnpm --dir chat exec vitest run components/dashboards/admissions/events/__tests__/event-detail-panel.test.tsx --maxWorkers=1 (7 passed)

- pnpm --dir chat typecheck

- pnpm exec biome check chat/components/dashboards/admissions/events/event-detail-panel.tsx chat/components/dashboards/admissions/events/__tests__/event-detail-panel.test.tsx

- pnpm lint:test-architecture

Closes #1949

#1952 — fix(admissions): read event start times as UTC and show them in the viewer's timezone (AERIE-2378) @marcusdAIy  approved

## Summary

The Admissions events dashboard now reads each event's start time as UTC and shows it in the viewer's timezone. The detail panel also shows the start time with a zone label (e.g. "Dec 11, 2026, 10:00 AM EST").

## Why it's needed

AERIE-2378: EduCRM syncs start_date_time as a naive YYYY-MM-DD HH:MM:SS stored in UTC. The dashboard passed that string to new Date(), which reads it as browser-local time. Late-evening events landed on the wrong day in the table, mobile view, detail panel and CSV export. The date-range filter also compared the raw UTC string to the viewer's calendar days.

## Changes

- New events/event-start.ts:

- parseEventStart reads the stored value via normalizeRfc3339DateTime, which treats naive values as UTC.

- formatEventDate and formatEventDateTime format in the viewer's timezone.

- eventStartDay returns the viewer-local calendar day.

- events-table.tsx, events-mobile.tsx: use the shared formatEventDate; the old copy in events-table.tsx is removed.

- event-detail-panel.tsx: shows the date, time and zone.

- derivation.ts: the CSV date and the date-range filter use the viewer-local day.

- Tests:

- New event-start.node.test.ts, with fixed timezones: 15:00Z shows as 10:00 AM EST in New York and 21:00Z as 1:00 PM PST in Los Angeles, and 02:00Z falls on the previous day in New York.

- The detail-panel assertion now expects the date and time, in a form that doesn't depend on the test machine's timezone.

## Breaking changes

None. No sync change or backfill; display only.

## Test plan

- [x] pnpm vitest run components/dashboards/admissions/events (43 passed, both with the local timezone and with TZ=UTC)

- [x] Chat typecheck (pre-commit), biome

#1950 — fix(public-api): honor change-history limits up to 100 and fill filtered pages (AERIE-2456) @marcusdAIy  approved

## Summary

Site change history now honors limit up to the declared maximum of 100, and a resource or field filter keeps scanning until the page holds limit matches. Previously every page scanned only 10 source audit events, so the default limit of 25, and anything above 10, never took effect.

## Why it's needed

AERIE-2456: the operation declares limit 1–100 (default 25), but the source window was hard-capped at 10 events and the response schema at 10 items. A caller planning a walk from the declared limit made 10× the calls planned, hit nine 429s on this route, and a filtered walk took as many calls as an unfiltered one.

## Changes

- convex/publicApi/v2/domains/siteChangeHistory.ts: raises the per-query source window from 10 to 100.

- convex/publicApi/v2/domains/portfolio.ts: listPortfolioSiteChangeHistoryV2 loops the page query until the page holds limit matching events, the source runs out, or the response has scanned 500 source events. scan.sourceEvents and scan.matchedEvents report the totals, and the cursor continues from the last source page read.

- lib/public-api/v2/domains/portfolio.ts: the response schema allows data up to 100, matchedEvents up to 100 and sourceEvents up to 500. The operation description now explains the fill behavior, the scan window, and when a filtered page can still be short or empty.

- Tests:

- New: 30 site audit events; the default limit returns 25, and a continuation returns the last 5 with hasMore: false.

- Updated: the filtered limit=1 case now returns its match on the first page (scan: {sourceEvents: 2, matchedEvents: 1}) instead of an empty page plus a continuation.

## Breaking changes

None to the request contract. Pages are now larger, up to limit events, and the data/scan maximums in the response schema are raised.

## Test plan

- [x] pnpm vitest run convex/publicApi lib/public-api (767 passed; the one failure is the local CRLF-only skill-package test, which also fails on main)

- [x] Chat typecheck (pre-commit), biome

#1946 — fix(dbt): restore All Program utilization precision @vvp-trilogy  approved

Restore All Program utilization precision/defaults while preserving SIS enrollment as the numerator. Follow-up to #1922 and #1945; no source switching or new production models.

## RCA and fix

Legacy reports ROUND(enrollment / capacity, 4) for positive capacity and zero otherwise. Aerie rounded to two decimals and returned null for missing source capacity. The measured current-year discrepancy breakdown before this correction was 60 null-default cases and 22 precision cases.

Update that one expression, retain SIS enrollment from #1945, and validate against published capacity/current_enrollment rather than rejoining staging in the contract test. No cap at 100%. Add a not-null contract and deterministic missing/zero/negative capacity, missing enrollment, repeating-fraction and over-capacity fixtures.

## Validation

- Independent RCA/code review: no findings.

- Isolated local +mart_all_program build: 113/113 passed.

- Focused reporting tests: 19/19 passed, including SIS Enrollment report reconciliation.

- 95 keys per year: zero formula mismatches and zero null ratios.

- Current-year legacy comparison: three material ratio differences remain, all explained by the intentionally retained SIS headcount differences. No material differences where enrollment counts agree. Prior year has fourteen headcount-driven differences.

- Redshift/legacy floating representations differ by at most 2.22e-16 when counts agree; comparison uses 1e-10 tolerance to distinguish representation noise from metric differences.

- git diff --check: passed.

The source-of-truth correction is deliberate: match the SIS Enrollment report, not legacy HubSpot enrollment counts. Only 30/90-day forecasts are deprecated; other measures continue in separate corrections.

#1948 — test(chat): apply the 30s test timeout to every Vitest project @marcusdAIy  approved

## Summary

Sets testTimeout: 30_000 on each of the edge, node and browser Vitest projects. Until now the root-level testTimeout never applied, so every test ran with Vitest's 5-second default.

## Why it's needed

Inline Vitest projects don't inherit root test options unless they set extends: true, so the root testTimeout: 30_000 in chat/vitest.config.ts was dead config. Heavy tests that take a few seconds locally cross 5 seconds on slower CI runners. Yesterday that forced Test reruns on 4 PRs and failed main's CI once. Failures included the global-documents admin page test, a skill-governance test, two agentRuns HTTP route tests, and the first test in convex/publicApi/v2/admissions.test.ts on a cold start.

## Changes

- chat/vitest.config.ts: adds a shared PROJECT_TEST_TIMEOUT_MS = 30_000, sets it as testTimeout on each project, and removes the ineffective root testTimeout. I set it per project rather than with extends: true, which would also pull the root exclude and server options into each project.

## Breaking changes

None. A test that hangs now fails at 30s instead of 5s.

## Test plan

- [x] A throwaway 6-second test passed under the edge project. Without this change it times out at 5s.

- [x] pnpm vitest run convex/publicApi/v2/admissions.test.ts "app/(main)/admin/global-documents/page.test.tsx" lib/public-api/v2/domains/finance.node.test.ts (one file per project, 63 passed)

#1947 — fix(public-api): capture the demographics refresh 503 as an expected outcome (AERIE-2881) @marcusdAIy  approved

## Summary

Adds admissions_demographics_refresh_in_progress to the v2 expected readiness codes, so the demographics routes' planned "refreshing, retry shortly" 503 is captured as an expected_outcome instead of an unexpected Platform Error.

## Why it's needed

AERIE-2881: GET /v2/admissions/programs/{programId}/demographics intentionally returns this retryable 503 (with Retry-After: 300) while a demographics refresh is in progress. It has the same shape as the readiness codes already in the registry, but it wasn't listed, so all 29 occurrences landed in Platform Error triage.

## Changes

- chat/convex/publicApi/v2/platformOutcomes.ts: adds the code to EXPECTED_READINESS_CODES.

- chat/convex/publicApi/v2/platformOutcomes.test.ts: adds the code to the readiness-gate classification table.

- chat/convex/publicApi/v2/admissions.test.ts: the existing refresh-in-progress route test now also asserts that both demographics routes captured their 503 with disposition expected_outcome and the matching outcome code.

## Breaking changes

None. Response status, body and headers are unchanged; only the capture disposition changes.

## Test plan

- [x] pnpm vitest run convex/publicApi/v2/platformOutcomes.test.ts convex/publicApi/v2/admissions.test.ts

- [x] Chat typecheck (pre-commit), biome

#1945 — fix(dbt): align All Program enrollment with SIS report @vvp-trilogy  approved

Use existing SIS Enrollment report cohorts for All Program current enrollment. Follow-up to #1922; runtime cutover #1923 is unchanged.

## Source of truth

The user clarified that enrollment must come from SIS and match the SIS Enrollment report. Minor differences from legacy HubSpot reporting are accepted. Exact legacy parity must not introduce a second enrollment classifier or switch the source back to HubSpot.

The earlier HubSpot reconstruction in this branch is removed completely: no new enrollment fact models, merge-alias staging, session-date staging, or HubSpot enrollment classification remains in the final diff.

## Change

One small CTE aggregates existing int_enrollment_cohort on-campus facts at Program/year using the existing identity mapping. It uses the same enrollment-row grain as mart_enrollment_dtl, defaults absent counts to zero, and works for both reporting years. The other metrics retain existing sources. The ratio takes the corrected SIS numerator; precision/default parity is a separate follow-up.

Using current Forecast output alone cannot cover the previous-year report: its reporting spine omits that year. Direct aggregation of the existing SIS facts avoids that coverage issue without duplicating classification.

## Validation

- Independent review: no blocking findings; confirmed the CTE matches the report's on-campus fact population and count grain.

- Local isolated build of affected reporting models/tests: 19/19 passed, no warnings.

- New deterministic fixture proves SIS on-campus facts win over a conflicting Forecast count, and excludes first-day/future-start cohorts.

- New reconciliation test compares the final mart directly to the SIS Enrollment report.

- All 95 Program keys match the SIS report in both years: zero metric differences; totals 1,487 for 2026 and 613 for 2025.

- Legacy comparison is informational: six current-year program differences (legacy total 1,482); fifteen prior-year differences (legacy total 609). No source switching or hardcoded offsets to erase them.

- git diff --check: passed.

Only 30/90-day forecast metrics are deprecated. Other retained metric corrections will follow individually, preserving each governed report's source of truth.

#1944 — fix(dbt): restore All Program capacity parity @vvp-trilogy  approved

Restore the All Program capacity contract: missing HubSpot student_capacity is reported as zero, while populated and explicit-zero values pass through unchanged. This fixes 60 of 95 current-year program rows; both sources already total 2,063 seats.

RCA: educrm-reporting stages property_student_capacity, passes it through its program dimension, and applies COALESCE(student_capacity, 0) at the reporting boundary. Aerie extracted all populated accepted source values but omitted the final default. An independent agent verified the source chain and fix.

Source nulls stay intact in staging. Utilization is a separate parity correction: its current source-capacity behavior remains unchanged, and its assertion now checks that source operand explicitly.

Adds a deterministic missing/zero/populated capacity fixture and a non-null contract test.

Validation:

- Local isolated +mart_all_program ancestor build: 111/111 passed.

- Focused reporting intermediate/mart tests: 14/14 passed.

- Warehouse full-key comparison: 95 matched programs in each of 2025 and 2026, zero missing/extra keys, zero capacity mismatches, 2,063 seats in each table/year.

- Independent agent code review: no findings.

- git diff --check: passed.

Follow-up to #1922; prerequisite for #1923. User-directed correction to preserve legacy measure semantics, excluding only 30/90-day forecasts.

#1942 — dbt: publish governed all-program mart @vvp-trilogy  approved

## Summary

- type the accepted HubSpot Program properties required by the compatibility mart and stage accepted owners without excluding archived referenced owners

- build a two-year Program reporting intermediate from direct source attributes and governed Forecast facts

- publish a contracted 36-column mart_all_program with preserved missing coverage and typed-null retired measures

- add grain, cardinality, source-filter, owner-resolution, schema, and business-identity tests

Closes #1922.

## Validation

- dbt parse: passed

- warehouse-backed dbt compile --select +mart_all_program --vars {pr_number: 1922}: passed

- isolated ancestor build: 110/110 passed

- focused rebuild: 3/3 passed

- focused data and unit tests: 24/24 passed

- git diff --check: passed

## Production comparison

Compared staging_education.sales_educrm_wh_mart_all_program with sandbox_education.pr1922_mart_all_program at (program_id, school_year).

- Grain: 190 joined keys; 0 missing; 0 extra; 95 Programs in each of 2025 and 2026.

- Direct attributes with zero normalized mismatches: program id/code/name, grade levels, owner id/name, expansion, school status, visibility, program start date, school address, latitude, longitude, and all four year labels.

- All 86 referenced owners resolve; 3 resolve through archived accepted owner rows.

- Capacity intentionally preserves accepted-source nulls: 120 PR null rows versus 0 legacy null rows, where legacy defaulted missing capacity to zero.

- Governed Forecast coverage remains observable: same-year measures have 129 null rows (all 95 rows for 2025 plus 34 uncovered Programs for 2026); next-year measures have 68 null rows (34 uncovered Programs in each reporting year).

- Retired columns are typed null on all 190 rows: projected_enrollment_30d, projected_enrollment_90d, deposits_all_after_next_year, current_enrollment_next_year, first_day_enrollment_next_year.

Per-column legacy comparison (mismatches / legacy nulls / PR nulls):

| Column | Mismatches | Legacy nulls | PR nulls |

|---|---:|---:|---:|

| program_id | 0 | 0 | 0 |

| program_code | 0 | 0 | 0 |

| program_name | 0 | 0 | 0 |

| grade_levels | 0 | 0 | 0 |

| owner_name | 0 | 18 | 18 |

| owner_id | 0 | 18 | 18 |

| is_expansion | 0 | 56 | 56 |

| school_status | 0 | 0 | 0 |

| show_in_dashboard | 0 | 176 | 176 |

| capacity | 120 | 0 | 120 |

| current_enrollment | 134 | 0 | 129 |

| current_enrollment_pct | 178 | 0 | 156 |

| coming_enrollments | 146 | 0 | 129 |

| graduating_students | 141 | 0 | 129 |

| re_enrolled | 152 | 0 | 129 |

| pending_re_enrollment | 129 | 0 | 129 |

| declined_re_enrollment | 138 | 0 | 129 |

| coming_withdrawals | 129 | 0 | 129 |

| projected_enrollment_30d | 190 | 0 | 190 |

| projected_enrollment_90d | 190 | 0 | 190 |

| new_enrolled_next_year | 140 | 0 | 68 |

| deposits_in_progress_next_year | 114 | 0 | 68 |

| deposits_next_year | 161 | 0 | 68 |

| deposits_all_after_next_year | 190 | 0 | 190 |

| current_enrollment_next_year | 190 | 0 | 190 |

| first_day_enrollment_next_year | 190 | 0 | 190 |

| projected_enrollment_next_year | 170 | 0 | 68 |

| program_start_date | 0 | 72 | 72 |

| school_address | 0 | 34 | 34 |

| school_latitude | 0 | 40 | 40 |

| school_longitude | 0 | 40 | 40 |

| deposits_year_label | 0 | 0 | 0 |

| deposits_future_label | 0 | 0 | 0 |

| projection_year_label | 0 | 0 | 0 |

| school_year | 0 | 0 | 0 |

| school_year_label | 0 | 0 | 0 |

Forecast and retired-field mismatches are the ticket's explicit semantic migrations, not parity failures. Coordinates and direct attributes match production after scalar normalization.

#2173 — feat(education): accept additive CBA BU perimeter changes @sanketghia  approvedmercy-allow-critical

## Summary

- Move Core, Mart, and the runner to education_plan_actual_variance_v3, preserving v1/v2 history.

- Keep CBA as the all-history BU source, accept new additions, and log their names.

- Block removal of any BU present in a completed v1, v2, or v3 snapshot.

- Keep the SY25/26 amount window unchanged.

## Validation

- uv run pytest tests/test_sql_contracts.py tests/test_handler.py -q: 20 passed.

- git diff --check, DDL mirror comparison, pipeline JSON parsing, and changed Python syntax checks passed.

- The DDL dry run parsed 8 statements; it made no Redshift changes.

- The live v1 roster (34 BUs) is a subset of the latest v2 roster (35 BUs).

- The Redshift integration test was not run because it performs production writes.

## Deployment

Production remains on runner v2. Apply the procedure DDL and deploy the v3 runner as a coordinated cutover. No production DDL or pipeline invocation was performed for this PR.

#1939 — feat(public-api): bounded per-key rate-limit overrides with an expiry @kevalshahtrilogy  approved

## Testing contract

### What this PR delivers

An operator can temporarily raise one public API key's rate limits, per tier, for a bounded window. Today every key gets fixed limits (standard 120, write 30, sensitive 10 per minute) with no way to lift them for one consumer. The first use is the Rhodes service key: the DSS registry seat's full read of notes, documents and change history is capped by the sensitive tier at 10/minute and takes over an hour. Raising that key's sensitive tier to 100/minute for two weeks brings the read down to minutes, with no database access and no new pipeline.

### Who uses it and where

- Operators with Convex deploy access set and clear overrides with npx convex run publicApi/keys:setRateLimitOverride and publicApi/keys:clearRateLimitOverride. Both are internal mutations; there is no new public route, UI or capability.

- API key holders see the effect on /v2/* and the agent-context route: the tier bucket uses the override, and X-RateLimit-Limit / X-RateLimit-Remaining report it.

### Conditions needed

- An active Aerie direct API key (aerie_sk_).

- Overrides need an end time: until must be in the future and at most 30 days out.

- Each tier must be a whole number from its default to 10x its default (standard 120-1200, write 30-300, sensitive 10-100).

### Expected behavior and examples

- setRateLimitOverride({ apiKeyId, overrides: { sensitive: 100 }, until: <now + 14 days> }) → sensitive reads on that key return X-RateLimit-Limit: 100; the 101st sensitive request in a minute is 429. Standard and write tiers are unchanged.

- After until passes, the key falls back to the defaults on its next request with no cleanup needed; clearRateLimitOverride removes it early.

- Rejected: { sensitive: 101 }, { sensitive: 5 }, non-integers, an empty override, an until in the past or more than 30 days out, revoked or non-Aerie keys.

- Denied-request and rate-limit-audit buckets keep their fixed sensitive budget; an override never loosens them.

- The published per-operation requestsPerMinute in discovery still shows the default, which is still what a key without an override gets.

Verified: keys.test.ts (window, expiry, clear, bounds), v2/http.test.ts (header shows 240 under a standard override, 120 after expiry), plus rateLimit, http and agentContext/http suites; 98/98 pass. pnpm typecheck, biome and the repo lint scripts are clean.

### Limits and unanswered questions

- Every request on a key writes the same rate-limit window row; higher limits mean more same-row writes. consumePublicApiRateLimit already retries write conflicts. 100/minute on the sensitive tier is well within that, but a near-1200/minute standard override should get a load check first.

- No UI: overrides are visible only in the apiKeys row. Fine for a rare operator action; worth an admin view if it becomes routine.

## Business Value

Unblocks the DSS registry seat's scheduled full read of Rhodes, which grades the dataset on a schedule and today needs over an hour of shared sensitive-tier budget, competing with every other Rhodes caller. It answers Neeraj Gupta's 2026-10-16 request (read-only DB access, or 10x for two weeks) with the safe option, without opening database access or building an export pipeline. It also gives us a reusable, bounded lever for any future consumer that needs a temporary burst.

## Manual Effort Estimate

~4-6 hours focused (read the auth and limit path, add schema fields, mutations, bounds, tests). Proposed by Claude; Keval to confirm or adjust.

Linear: ticket pending, to be linked.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#2172 — fix(retention): accept withdrawal-on-report-start boundary residual (SURTR-1213) @kevalshahtrilogy  approvedheimdall-driven

Linear: SURTR-1213

## Summary

mart-aerie-retention-refresh has failed every run since 2026-09-23 with aerie retention: 4 learner-level monthly residuals lack an accepted explanation.

Root cause (logic gap). Since #1830 the opening count excludes a learner whose effective withdrawal date equals the opening reference (> opening_reference_date, workbook boundary), but the first month still counts that withdrawal (BETWEEN month_start AND month_end). Each such learner contributes -1 to opening + starts - withdrawals - closing. Only the mirror case (start on report start, +1) was an accepted explanation, so the check fired. Reproduced read-only against the live roster with the procedure's temp-table logic: exactly 4 learner-months, all contribution = -1, all effective_withdrawal_date = report start (SIS withdraw date 2025-07-01, start dates 2024-10 to 2025-01, i.e. before the window), all in the first month. After the fix those 4 are explained and 0 remain unexplained, for both report ends 2026-08-31 and 2026-09-30.

Fix. Add withdraws_on_report_start (effective withdrawal = report start AND first month only) as an accepted explanation in the procedure check, the handler post-refresh check, and reconciliation/003. Not a relaxation: the check still fails for any other nonzero contribution, the exemption is limited to the first month, and the per-month residual-total check (monthly residual totals lack learner-level evidence) is untouched. No learner is excluded and no published number changes.

Why it surfaced now. The 4 learners sit in campuses outside the original two (Austin, Miami, Texas Sports Academy). Production currently runs the SURTR-1510 "all mapped campuses" version of the procedure (draft #2049, d1f7c1a3), which is not on main. With the two original campuses there is no such learner. This patch applies cleanly to both main and the #2049 branch (git apply --check verified).

## Business Value

Restores the Aerie retention marts (learner detail, campus-month waterfall, cohort retention), stale since the 2026-09-23 publish, so education leadership retention reporting refreshes again, and removes a recurring failure class: any campus with a July-1 withdrawal and an earlier start would break the pipeline each year at the report-window edge.

## Manual Effort Estimate

Proposed: ~1.5 days of focused time by hand (reproduce the temp-table logic in Redshift, find that prod runs a non-main procedure, derive the boundary algebra, patch three call sites plus tests). Keval to confirm or adjust.

## Test plan

- [x] uv run ... pytest: 272 passed (4 new executable boundary tests in tests/test_residual_boundaries.py; the first and the first-month-scoping test fail without the fix)

- [x] ruff@0.15.22 format/check clean

- [x] Read-only live replication: 4 -> 0 unexplained residuals

## DDL apply runbook (manual; needs owner role Surtr_Service_User; not applied by this PR)

1. Production's mart_education.sp_refresh_aerie_retention is the SURTR-1510 variant. Re-creating it from this branch's main-based file would silently revert the all-campus scope. Apply the patch to what prod runs instead: git apply this PR's diff of ddl/002_sp_refresh_aerie_retention.sql onto the #2049 file (or merge #2049 first, then this), then run that file as Surtr_Service_User.

2. Deploy the Lambda (src/handler.py post-refresh check) with the normal pipeline release.

3. Trigger an ON_DEMAND refresh and confirm the run succeeds and unexplained_residual_learner_count = 0, explained == absolute reconciliation delta.

No learner identifiers appear in this PR.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#2171 — fix(education): accept GuidePlatform schema drift on five relations (SURTR-1016, SURTR-1588) @kevalshahtrilogy  approvedheimdall-driven

## What / Why

guide-platform-raw-sync (state machine pipeline-guide-platform-raw-sync-prod) has failed every scheduled run since 2026-09-23 with SourceSchemaDriftError: the GuidePlatform Postgres source gained additive columns and the pipeline does exact-shape validation. No new GuidePlatform data has landed since, and downstream mart-education-guide-roster-refresh is stale. This accepts the drift through a reviewed contract change (the check is not relaxed or bypassed). Linear: SURTR-1016, SURTR-1588.

Drift (live types via scripts/generate_contract.py read-only introspection, not guessed):

| Table | New columns |

| --- | --- |

| audio_recordings | rerun_history jsonb NOT NULL |

| fix_it_reflections | file_url, file_name, file_mime (text, nullable) |

| limitless_meetings | paste_meta jsonb |

| students | pronouns_guide text, pronouns_guide_set_by uuid, pronouns_guide_set_at timestamptz |

| survey_templates | instrument_variant_id uuid |

| weekly_reports | report_kind text NOT NULL, report_number smallint, community_seq integer NOT NULL |

Note: the task listed five relations; fix_it_reflections first appeared in the 2026-10-16 05:35 UTC run, so it is included here so one deploy clears the failure.

## Changes

- src/contract.py, ddl/001_create_staging.sql, contracts/legacy_clean_compatibility.json: regenerated; contract hash bf4df98c... -> 355674e3d66a954f71ce34bba32b740f63a77c1fc4d1ebab7bb7cdcf511c6dce.

- ddl/009_source_drift_20261016_part1.sql (audio_recordings, fix_it_reflections, limitless_meetings) and ddl/010_source_drift_20261016_part2.sql (students, survey_templates, weekly_reports): ddl/006-008 hardened pattern (plain CREATE VIEW after DROP, no GRANT/OWNER text, live owner/ACL snapshot-and-replay, same-transaction guards). Two parts because six relations exceed the Data API 40-statement atomic batch cap. Part 1's post-apply catalog check is scoped to its relations; part 2 verifies the full 57-view catalog.

- scripts/run_ddl.py: --apply-migration source-drift-20261016-part1|part2 built on the existing N-relation helper.

- Consumer pin propagation in mart-education-guide-roster-refresh (handler, verify_source_inventory, ddl/001, integration harness, spec docs) and new ddl/008_20261016_source_contract.sql.

- Docs: contracts/schema-drift-2026-10-16.md, README.

## Evidence

- Failing run log (CloudWatch /klair/pipelines/prod/guide-platform-raw-sync, 2026-10-16 05:35 UTC) lists exactly the six relations above.

- generate_contract.py --check exits 0 (contract and DDL match live source); 57 tables.

- Producer: 216 passed, 1 skipped (new tests/test_source_drift_20261016.py covers canonical-view match, plain CREATE, no grants, guard/replay ordering, 40-statement limit, token binding, apply with fake Data API incl. scoped vs full verification, refusal paths, dry run). Consumer: 78 passed (excluding live-DB integration). ruff 0.15.22 format/check clean.

- Not applied to Redshift; nothing merged.

## Manual apply runbook (owner step, Keval)

1. Confirm sys_query_history has no conflicting DDL on staging_education_guide_platform.

2. From pipelines/runners/guide-platform-raw-sync, as admin (dry run first by omitting --apply):

   REDSHIFT_DB_USER=admin uv run python scripts/run_ddl.py --apply --apply-migration source-drift-20261016-part1

REDSHIFT_DB_USER=admin uv run python scripts/run_ddl.py --apply --apply-migration source-drift-20261016-part2

Part 2 must print Verified exact clean-view catalog: tables=57. Both parts must be applied before the new producer image deploys.

3. Merge and deploy (producer + consumer images).

4. Apply mart-education-guide-roster-refresh/ddl/008_20261016_source_contract.sql via that runner's scripts/apply_ddl.py (trigger disabled; known access-visibility gap documented in schema-drift-2026-09-29.md still applies).

5. Run mart-education-guide-roster-refresh/scripts/verify_source_inventory.py.

6. Trigger an on-demand run of pipeline-guide-platform-raw-sync-prod, then mart-education-guide-roster-refresh, and confirm success.

## Business Value

Restores the GuidePlatform raw sync that has been down for ~3 weeks. Every Guide/education dashboard and the Guide roster evidence mart fed by it is stale until this ships, and each additional day adds backlog and daily alert noise. The change also leaves the exact-shape guard fully intact, so future unreviewed drift still fails closed.

## Manual Effort Estimate

Proposed: ~12 focused hours (live introspection and contract regeneration, two hardened migrations with ACL replay, generalizing the apply tooling past the 40-statement cap, consumer hash propagation, tests, runbook). Keval to confirm / adjust.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#2170 — fix(truefoundry-gateway-pipeline): reconcile against the ungrouped API total (SURTR-1526) @kevalshahtrilogy  approvedheimdall-driven

Linear: SURTR-1526

## What

truefoundry-gateway-pipeline has hard-failed its FR5 reconciliation gate on most days since 2026-09-22 (redshift_total=128127.14, tf_api_total=0). The ingest itself is fine (28 partitions, 0 failed); the reference total is wrong.

Root cause (verified against the live API): the Metrics API stopped returning the null-virtualModelName provider layer on 2026-09-22. Every grouped row is now named, so fetch_daily_total_usd's provider-layer filter sums to $0.00. Not T+2 lag — it is $0.00 for every day since, including 2026-09-23.

Fix: when the grouped response has no null-name rows, ask the API for the ungrouped total. If the null layer ever returns, the old path is unchanged.

## Evidence (read-only, prod, same API key the pipeline uses)

Ungrouped total reproduces the old provider-layer figure the logs recorded on 9-16 ($80,061 both ways), and tracks Redshift:

| day | redshift | api ungrouped |

|---|---|---|

| 09-16 | 68,022 | 80,061 |

| 09-20 | 38,926 | 46,148 |

| 10-06 | 111,090 | 119,995 |

| 10-12 | 134,703 | 147,215 |

| 10-14 | 128,127 | 137,185 |

Gate (25% and $3,000) would pass on all of these.

Separate finding, not fixed here: 2026-09-30 shows redshift $73,007 vs api $153,906, and 10-02 $96,892 vs $119,062. That looks like a real ingest gap on those days (the gate only checks T-2, so nothing caught it). Tracked separately.

## Tests

Two new tests (fallback to ungrouped when the provider layer is absent; no fallback when it is present). Pipeline suite: 77 passed. Ruff 0.15.22 clean.

## Business Value

Removes a daily false failure on the pipeline that feeds TrueFoundry spend into the AI budget views, and restores the gate as a real integrity check (a $0 reference currently hides genuine ingest gaps).

## Manual Effort Estimate

Proposed ~1 day (diagnosing the API change, verifying against Redshift, fix and tests). Keval to confirm.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#1936 — fix(public-api): accept Completing Construction as a document upload link once stored @kevalshahtrilogy  approved

Linear: AERIE-2884

## What

POST /v2/portfolio/sites/{siteRef}/documents refused linkedObject {kind: milestone, key: completingConstruction} with 422 linked_object_site_mismatch, even on sites where the milestones read lists it as active. The guard checked keys with isMilestoneKey, which covers the flat site milestone record and deliberately excludes the per-phase completingConstruction. The OpenAPI schema, the Convex arg validator and the documents.milestone column all already accept every ordered milestone key.

The guard now accepts completingConstruction when the site stores it in Phase 1 (the same rule phase1MilestoneSequence applies to the milestones read) and still returns 422 when it doesn't. Every other milestone key is unchanged.

Found by Artemis's Rhodes data-quality loop (Kirkland construction evidence was being registered at site level instead of milestone 5).

## Tests

New documentsHttp.test.ts case: unstored → 422 linked_object_site_mismatch; stored → reservation with registerAs.milestone = completingConstruction. The full file passes (49 tests).

## Business Value

Construction-phase evidence (photos, frame tiles) can be filed against milestone 5 through the API, so Completing Construction progress and its document trail line up in Rhodes instead of piling up as unattached site documents. It also clears an open data-quality report from the ops loop.

## Manual Effort Estimate

About 1.5 hours by hand: tracing the 422 to the narrower key set, the guard change and the regression test. (Proposed; Keval to confirm.)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#3872 — revert(performance-review): load BU catalog from master mapping @sanketghia  changes requested

## Summary

- Revert the Performance Review BU catalog implementation from PR #3871, restoring the prior catalog and filter behavior.

- This is required since after extensive testing, there were discrepancies seen with the data that is returned from the backend.

- Reverts commit 96ebd567c4a35de70c033010c58d2862f4192583.

Linear: [KLAIR-3603](https://linear.app/builder-team/issue/KLAIR-3603/add-permission-aware-bu-catalog-for-performance-review-filters)

## Validation

- Frontend checks were verified by the author before PR creation.

- No test suites were run in this session.

#2169 — Q22: classify Central classes and enable on-demand refresh @sanketghia  approvedmercy-allow-critical

## Summary

- Add Finance-governed mappings for the 78746 Austin K-8 and Austin Preschool Spyglass classes to Alpha Austin.

- Classify the 11 approved realm/class identities as central in Q22, preserve class-level detail, and fail closed if a Central class overlaps an effective school mapping.

- Keep the five unlisted classes as unmapped_class and classless lines as classless.

- Enable explicit on-demand Q22 invocation while retaining the QuickBooks Core-success trigger and no independent schedule.

- Extend the README and reconciliation SQL for Central, school-mapped, unlisted, and classless outcomes.

## Production verification

- Applied the guarded crosswalk migrations and Q22 procedure DDL; verified the installed procedure owner, signature, grants, and body parity.

- Created and independently checked row counts for the 27-table pre-refresh backup.

- Local Q22 preflight passed and a production publication completed as q22-local-8f7aa012a05340f2a2bc3676a6cb8edf.

- Published controls: 13,754 Fixed Asset lines, 226 included 62700 lines, 72 excluded zero-net allocations, 14,052 detail rows, 193 aggregate rows, and zero findings.

- Independently reconciled all 193 detail/aggregate keys with zero mismatches.

## Tests

- Test suite not run.

#1931 — fix(public-api): serve unparseable forecast and event dates as null instead of the raw value (AERIE-2377) @marcusdAIy  approved

## Summary

Forecast and event date fields in the v2 Admissions API now go through one strict helper pair. An unparseable stored value is served as null instead of passing the raw value through, which would fail the whole response with 500 operation_response_schema_mismatch.

## Why it's needed

AERIE-2377: the local normalizeForecastDateTime / normalizeForecastDateOnly helpers returned the raw input whenever normalization failed, and normalizeForecastDateTime skipped anything containing a T. A T-separated value without an offset, a date-only value, an impossible date, or any format drift upstream would reach the date-time/date validator unchanged. One bad row would take down the whole forecast response for that Program.

## Changes

- packages/contracts/src/date-normalization.ts adds two helpers:

- normalizeRfc3339DateTime returns canonical RFC 3339 (T separator, Z or ±HH:MM). It treats naive values as UTC, normalizes +00 and -0530 offsets, and returns null for date-only or unparseable values.

- normalizeIsoDateOnlyOrNull returns YYYY-MM-DD from an ISO date or timestamp, or null.

- chat/convex/publicApi/v2/domains/admissions.ts uses them for every forecast calculatedAt, targetDate, lockedAsOfDate, forecastLockedAt, actualAsOfDate, rate effectiveDate and observation-window date, and for the events startDateTime. It removes the three local helpers, including normalizeEventStartDateTime. All of these fields were already nullable, so the contract is unchanged.

- Tests:

- A table-driven test over the input matrix in the ticket (date-normalization.test.ts).

- A route test that stores a malformed calculatedAt and session1.targetDate, and asserts a 200 with null for both while the other year's values are still served.

## Breaking changes

None. Values that used to be served raw would have failed validation; they are now null.

## Test plan

- [x] pnpm vitest run src/date-normalization.test.ts in packages/contracts (22 passed)

- [x] pnpm vitest run convex/publicApi (562 passed before the rebase)

- [x] Chat typecheck (pre-commit), biome

#1928 — fix(public-api): fill in-memory admissions student pages past unreferenced rows (AERIE-2351) @marcusdAIy  approved

## Summary

Admissions student drilldowns that page through an in-memory row set (forecast, funnel and demographic students) now keep scanning past rows that have no public student reference. A page is short or empty only when its bounded source rows are exhausted, so they no longer return data: [] with hasMore: true.

## Why it's needed

AERIE-2351: forecasts/current/students?stageId=lead returned 0 records on every page with hasMore: true for three pages. Each 100-row slice was entirely rows withheld for a missing public reference (omittedCount: 100), and the bounded_source_rows reason took precedence in the label. An agent reading page 1 saw "0 students" with no signal that later pages would also be empty.

## Changes

- convex/publicApi/v2/domains/admissions.ts (publicStudentItems, offset path only): resolves refs for the remaining in-memory rows (at most 400), consumes rows until the page holds limit items, and puts the next cursor's offset after the last row consumed. omittedCount is the count of rows withheld on that page. The source-cursor path (enrollment students) is unchanged.

- lib/public-api/v2/domains/admissions.ts: the shared drilldown partialData handling explains that pages scan past withheld rows, and that empty data with isPartial=true means an incomplete source, not zero people.

- Test: 4 demographic students, of which only 2 have refs, walked with limit=1. Every page with hasMore=true holds a row, the walk returns both refs, and omittedCount sums to 2.

## Breaking changes

None. Cursors stay opaque offsets; pages can now hold more served rows per request (never more than limit).

## Test plan

- [x] pnpm vitest run convex/publicApi/v2/admissions.test.ts lib/public-api/v2/domains/admissions.node.test.ts convex/publicApi/http.test.ts convex/publicApi/admissionsDomainsHttp.test.ts lib/public-api

- [x] Chat typecheck (pre-commit), biome

#1927 — fix(public-api): fill filtered expense transaction pages across source slices (AERIE-2406) @marcusdAIy  approved

## Summary

Filtered expense transaction searches (searchTerm, school, or account filters) now keep scanning within a page until they hold limit matches or have inspected 1,000 source rows, instead of returning after a single 100-row source slice.

## Why it's needed

AERIE-2406: a searchTerm request could return an empty page with hasMore=true many times in a row, because each page only scanned one source slice the size of limit. Agents read the empty pages as "no results" or had to walk dozens of empty pages to find a few matches.

## Changes

- convex/publicApi/v2/domains/finance.ts: handleExpenseTransactions loops getTransactionsForApiV2 within a per-page scan budget of min(1,000, 5,000 − rows already read), accumulating rows and observedAt. The existing 5,000-row walk cap (expense_result_too_large) is unchanged.

- lib/public-api/v2/domains/finance.ts: the operation description and the finance.expenseReconciliation empty-result handling now describe the per-page scan budget and the walk cap.

- Test: 1,200 non-matching rows followed by one CERA row. Page 1 scans 1,000 rows and returns empty with hasMore=true; page 2 returns the match and ends the walk.

## Breaking changes

None. Unfiltered pages still issue a single source query; filtered pages can now return more rows per page (never more than limit).

## Test plan

- [x] pnpm vitest run convex/publicApi/v2/domains/finance.test.ts lib/public-api/v2/domains/finance.node.test.ts (29 passed, including the existing 5,000-row bound test)

- [x] Convex and chat typecheck, biome

#1926 — docs(public-api): state v2 rate-limit tiers and 429 handling in the skill and DSS front (AERIE-2439) @marcusdAIy  approved

## Summary

The Aerie skill and the DSS front document now state the v2 rate-limit tiers and their per-minute limits. They also say how the counters are scoped, how the window works, and which headers a 429 carries, so consumers can pace requests instead of learning the budget from 429s (AERIE-2439).

## Why it's needed

The v2 contract declares each operation's tier (standard 120, write 30, sensitive 10 per minute) in metadata and OpenAPI. None of the agent-facing documents mentioned it. The reporter asked three questions, answered here from apiKeyAuth.ts and keys.consumeRateLimit:

- Counter scope: per API key and per tier. The counter is shared by every v2 operation in that tier. It isn't per operation, and it isn't per owner.

- Window: fixed, aligned to the clock minute, with no X-RateLimit-Reset. Retry-After is the number of seconds until the next minute.

- Shared counters: no. /agents/context and /v1 use their own per-key counters, and the keyless /dss documents aren't rate limited.

## Changes

- chat/skill/aerie/SKILL.md: a new judgment rule, "Pace requests to the declared rate-limit tier", with the facts above and the success and 429 headers. The skill version is restamped with pnpm skill:version, which updates packages/contracts/src/aerie-api-skill.ts.

- chat/lib/public-api/dss.ts: the /dss/skill front door's how.note gains a short pacing sentence. The tier numbers are generated from PUBLIC_API_V2_RATE_LIMITS, and the sentence points to v2 meta reliability.rateLimit and the skill rule. No keys are added to the front-door shape.

- Tests:

- skill-package.node.test.ts checks the skill states every tier from PUBLIC_API_V2_RATE_LIMITS, plus the 429 headers.

- New dss.node.test.ts checks the same for the front-door note.

I didn't repeat this in each sensitive workflow's handling block. The ticket allowed one section instead, and per-workflow copies would drift.

## Breaking changes

None. Skill text changes and the skill version moves from 50674b22 to 4845158d, so installed copies will report as stale and should be reinstalled. The DSS front note text also changes.

## Test plan

- [x] pnpm vitest run lib/public-api/dss.node.test.ts lib/public-api/agent-context convex/publicApi/dss: 22 passed. The skill hash was computed from LF files, matching CI.

- [x] Full chat typecheck and biome

#1925 — docs(public-api): point readers to compliance.ahj and site notes when ahjPersonnel is empty (AERIE-2533) @marcusdAIy  approved

## Summary

When ahjPersonnel is empty, the served dictionary and enablement now tell readers to check compliance.ahj and the site notes before concluding that no AHJ contact is recorded (AERIE-2533).

## Why it's needed

ahjPersonnel was introduced on 2026-09-25 and is empty on most older sites. Those sites' AHJ names, roles and permit numbers live in the free-text compliance.ahj field and in site notes. The served text said an empty array "means none are recorded", which is true of the array but false of the site record. So a cold reader stopped there and missed contacts such as the Tulsa building director and fire marshal.

## Changes

chat/lib/public-api/v2/domains/site-people.ts: one shared sentence, AHJ_PERSONNEL_EMPTY_FALLBACK, names getSiteCompliance and listSiteCollaborationNotes. It's served in three places:

- the portfolio.site.people.ahjPersonnel field traps,

- the portfolio.site-people.inspect interpretation, now worded as "none are recorded in the list",

- portfolio.site-people.ahj-personnel handling.emptyResult.

site-people.node.test.ts asserts the sentence appears in all three served places.

The wording says older sites "may still carry" contacts only in those places, rather than promising a backfill.

## Breaking changes

None. Only dictionary and enablement text changes, so their revisions change.

## Test plan

- [x] pnpm vitest run lib/public-api/v2/domains/site-people.node.test.ts lib/public-api/agent-context: all passed except skill-package.node.test.ts, which also fails on main because of Windows CRLF line endings.

- [x] Full chat typecheck and biome

#3 — Restore exact original dictionary bytes and pin fidelity regression @caina-barbosa  no labels

Restores the exact authenticated raw Drive Dictionary.md bytes. The original readable-text copy process added one trailing LF; fields and prose are unchanged. Pins the independently measured original SHA-256 in the public HTTP dictionary test and records raw-file provenance.

The regression was red with staged hash38a965b5... and passes after restoring original hash74db39173b3e47ce6a286ca5a7528c13f3b8bd334a26969977627aefb23a7f58 (3,949UTF-8bytes). Corrects historical prose claims while preserving the actual first-deployment validation JSON/hash. No source API, roles, auth, infrastructure or permission changes. Local full check:28passed/1template-setupskip; build passes. Require fresh focused review, clean CI/Docker/secret scan and reviewed manual same-SHA diff/deploy before final acceptance.

#1924 — docs(public-api): document retired buildout phase fields as always null (AERIE-2395) @marcusdAIy  approved

## Summary

The portfolio.buildoutPlan dictionary object now has an entry for each of the ten retired Buildout phase fields. The four in the report are capexBid, occupancyType, permitApplicationSubmittedDate and constructionScheduledEndDate. Each entry gives the field's former meaning and says it is retired and always null (AERIE-2395).

## Why it's needed

getSiteBuildout still returns these fields so existing clients keep validating. The projection hard-codes them to null, and OpenAPI marks them deprecated. The dictionary, though, had no entry for any of them. So a reader who saw a value turn into null after the retirement couldn't tell whether it was unrecorded, withheld, or retired.

## Changes

- chat/lib/public-api/v2/domains/lifecycle-property.ts: added one field per retired key, built by a small retiredBuildoutField helper. Each entry gives:

- the field's former meaning,

- a nullMeaning saying it is always null because it is retired, not unrecorded or withheld,

- an enforced invariant: reads always return null and writes reject changes,

- traps: a previously seen value was retired, not lost, and the field will be removed.

- The retired-key type is derived from the contracts' canonical and editable key lists, so a key added to or removed from the retired set fails the typecheck until the dictionary is updated.

- Enum meanings match the executable schema (N/A for date fields, the occupancy codes for occupancyType, none for the capex numbers), as the catalog validator requires.

- lifecycle-property.node.test.ts: checks that the served dictionary documents every RETIRED_BUILDOUT_SECTION_KEYS field as retired and always null.

## Breaking changes

None. The dictionary gains fields and its revision changes. The wire response and OpenAPI are unchanged.

## Test plan

- [x] pnpm vitest run lib/public-api: all passed except skill-package.node.test.ts, which also fails on main because of Windows CRLF line endings.

- [x] convex/publicApi/dss/http.test.ts

- [x] Full chat typecheck and biome

#2 — Publish deployed Network enablement and acceptance evidence @caina-barbosa  no labels

Publishes the working HTTPS origin, source-faithful enablement and sanitized initial deployment acceptance evidence. Clarifies the repository-level deployment gate and resolves historical local-only documentation after the approved foundation/source release.

Initial foundation SHA618a82fb5f096848722c4de6fd56f10a6fd95dbe passed independent review, PR/main CI including clean Docker and secret scan, manual diff/deploy and HTTPS acceptance. Dictionary unchanged; source behavior, auth, roles and vendor permissions unchanged. The 18:45–18:46 BRT deployed run verified finite workflows and revoked both temporary validator keys. Two source-denied consoles, three disconnected skips and incomplete per-device/campus coverage remain explicit. This documentation/enablement release will follow the same CI/review/manual diff/deploy gates.

#1921 — docs(public-api): define Program demographics rows in the dictionary (AERIE-2449) @marcusdAIy  approved

## Summary

The dictionary entry for AdmissionsProgramDemographics now documents what the demographics route actually returns: rows[] and each row field, hasUnknownGender, lastUpdated, and the level codes. It also stops listing an aggregate field that the route never served (AERIE-2449).

## Why it's needed

The dictionary listed only program, aggregate and freshness. Without row-level meanings, a reader couldn't tell:

- which population current and future count,

- whether the two overlap,

- what total* adds up,

- what the Missing grade and the level codes mean,

- or what lastUpdated dates.

Without those answers, the counts couldn't be used for capacity or enrollment reads.

## Changes

chat/lib/public-api/v2/domains/admissions.ts:

- New dictionary fields:

- rows: one row per grade bucket with at least one counted student. A missing grade has zero counted students.

- hasUnknownGender.

- lastUpdated: epoch milliseconds of the latest contact refresh, the same value as freshness.asOf.

- rows.grade.

- rows.level: PK, LL, L1, L2, MS, HS, Other.

- The nine rows.{current,future,total}{Boys,Girls,Unknown} fields.

- rows.totalStudents.

- The meanings follow the existing code:

- current/future = a student contact with a non-withdrawn deal, at any deal stage, whose session_school_year is the current or a later school year.

- A student can be counted in both, so total* counts that student twice.

- The counts are not the SIS enrolled roster.

- Both periods use the contact's single gradeLevel (or enteringGrade).

- The row schema is pulled into DEMOGRAPHICS_ROW_SCHEMA and registered as the AdmissionsDemographicsRow component, so the row fields are checked against the executable schema like the forecast-actuals counts. The response shape is unchanged.

- aggregateObject takes servesAggregate: false, which demographics uses to drop the shared aggregate field. The "aggregate counts only" invariant moves onto rows.

- admissions.node.test.ts: covers the new fields, their schema sources, and the missing aggregate field.

## Breaking changes

None to the wire response. The dictionary changes, so its revision changes. openapi.json gains one additive AdmissionsDemographicsRow component schema, which no operation references.

## Test plan

- [x] pnpm vitest run lib/public-api: 192 passed. The one failure is skill-package.node.test.ts, which also fails on main because of Windows CRLF line endings.

- [x] convex/publicApi/v2/admissions.test.ts, convex/publicApi/dss/http.test.ts, convex/publicApi/admissionsDomainsHttp.test.ts, lib/__tests__: all passed except platform-error-smoke-inventory, which also fails on main for the same CRLF reason.

- [x] Full chat typecheck and biome

#1 — Add direct read-only UniFi Network DSS and scoped release foundation @caina-barbosa  no labels

Adds the reviewed direct read-only UniFi Network DSS from the actual DSS Template, preserving caller keys, feedback, source-native IDs, timestamps, nulls and raw undocumented ISP units. The finite GET workflows cover inventory, bounded ISP periods and scoped Network sites/devices/statistics. Dedicated source-secret injection and scoped manual OIDC release inputs target CentralFinance479395885256/us-east-1.

Local validation: 28 tests passed, type/lint/build/audit and no-lookup CDK synthesis passed. Fresh independent review passed after nullable-source/OpenAPI fixes. Local runtime container passed startup/docs/auth; cold Docker install timed out against npm, so CI must establish a clean Docker build before merge/release. Current source access includes detailed telemetry on three sampled consoles, two permission-denied consoles and three disconnected skips; no full dictionary coverage or campus mapping is claimed.

Human explicitly approved private repository and normal deployment. No source settings, vendor permissions, pipelines, caches or real recipient keys are included. See VALIDATION.md and DEPLOYMENT.md for evidence and rollout limits.

#1920 — fix(reference-sync): capture missing-active-link publish refusal as expected outcome (AERIE-2874) @marcusdAIy  approved

## Summary

When a QuickBooks or SIS directory publish is refused because an active School link points at a source row that is missing from the new generation, Reference Sync now records the failure as an expected outcome (school_source.active_link_missing) instead of an unexpected platform error (AERIE-2874).

## Why it's needed

The guard does what it should: it stops a publish that would orphan a human-made School link. But it threw a plain Error, so every retry of the stuck generation showed up as an unexpected error and generated noise. The refusal is a known data state that needs action from a person: unlink or re-map the School on the School Ontology "Missing QuickBooks Class" / Unlink page. It isn't a platform bug.

## Changes

- chat/convex/analytics/reference.ts

- The missing-active-link guard throws ConvexError({ code: "school_source.active_link_missing", message }). The message text is unchanged.

- The Reference Sync onError hook recognizes that code and captures with disposition: "expected_outcome" and outcomeCode: "school_source.active_link_missing". Every other failure is captured as before.

- The code isn't added to PLATFORM_ERROR_PUBLIC_EXPECTED_OUTCOME_CODES, because that list controls which codes browser clients may self-report as expected. This one only ever comes from the bearer-gated sync route.

- chat/convex/analyticsReference.test.ts: the existing missing-link test now also posts the publish through /sync/analytics/reference. It asserts the 500 response, the unchanged error text, and the captured errorEvents row.

## Breaking changes

None. The guard still refuses the publish, and the worker still gets a 500 with the same error message. Only the error-event disposition changes.

## Test plan

- [x] pnpm vitest run convex/analyticsReference.test.ts (22 passed)

- [x] sync: pnpm vitest run src/analytics/school-source-directory-refresh.test.ts (33 passed)

- [x] Convex typecheck and pre-commit typecheck-chat, biome

#1929 — test(global-documents): give the 300-row admin page listing test a 30s timeout @marcusdAIy  approved

## Summary

Gives the global-documents admin page test "says when more proposals or archived documents exist than it lists" a 30-second timeout. It is currently failing main's CI and every open PR.

## Why it's needed

The test renders 100 proposal cards twice and 200 archived rows, then runs role queries over them. That takes about 3.3s locally and crosses the 5s default on CI runners. It failed main's CI after the latest merge and both runs on the open PRs. The root testTimeout: 30_000 in vitest.config.ts doesn't reach the browser project because the projects don't set extends: true, so this test runs at the 5s default.

## Changes

- chat/app/(main)/admin/global-documents/page.test.tsx: passes 30_000 as this test's timeout.

## Breaking changes

None. Test-only.

## Test plan

- [x] pnpm vitest run "app/(main)/admin/global-documents/page.test.tsx" (12 passed)

#1919 — fix(public-api): capture an incomplete Article identity migration as an expected outcome (AERIE-2875) @marcusdAIy  approved

## Summary

A 503 article_identity_not_ready from the Forge Articles API is now logged as an expected outcome when the cause is the incomplete public-identity migration. The same code also covers two other failures, a duplicated Article public ID and an Article that can't be projected after a write. Those still log as unexpected errors.

## Why it's needed

AERIE-2875: the Articles routes fail closed on purpose until the Article public-ID migration is verified. The classifier didn't list this code as an expected readiness outcome, so each by-design 503 opened a Platform Error. Adding the code to the allowlist outright would also have hidden the duplicate-identity and projection failures, which need a data or code fix. That is the same split as the ambiguous event identity in AERIE-2872.

## Changes

- chat/convex/forge/publicApi.ts: the identity_not_ready read result now carries a reason: migration_incomplete, duplicate_identity, or projection_incomplete.

- chat/convex/publicApi/v2/domains/articles.ts: every article_identity_not_ready throw goes through one helper, which keeps each existing message and adds the matching reason. This covers the read unwrap, the write gate, the duplicate check, and post-write projection.

- chat/convex/publicApi/v2/platformOutcomes.ts: article_identity_not_ready is expected only with reason migration_incomplete.

- Tests:

- platformOutcomes.test.ts: covers the expected reason, the two unexpected reasons, and an error with no reason.

- articles.test.ts: the existing fail-closed HTTP test now checks the body's message and reason, and that the captured error event is expected_outcome.

## Breaking changes

None. Status, code and message are unchanged. The 503 body gains the optional reason field, which the v2 error schema already defines.

## Test plan

- [x] pnpm vitest run convex/publicApi/v2/articles.test.ts convex/publicApi/v2/platformOutcomes.test.ts convex/forge: 100 passed

- [x] Convex typecheck, biome, and typecheck-chat (pre-commit)

#1 — Expose Healthy Air dictionary through read-only DSS adapter @caina-barbosa  no labels

Expose the supplied Healthy Air dictionary through a direct read-only adapter of the existing API. Fourteen allowlisted GET operations retain actual operation IDs and filters, source payloads, timestamps, confidence and nulls; public discovery includes the eight-object, 73-field dictionary, enablement and OpenAPI.

The actual DSS Template's registry, feedback and manual main-only OIDC release controls remain. Production injects the source key from a dedicated Secrets Manager secret; data calls have a fixed upstream, redirect rejection, a 60-second timeout, a 16 MiB response bound and redacted failures. No source writes, snapshot collector, controls or scheduled polling.

Validation: 47 tests passed (one template-only test skipped), lint/types/build/synthesis passed; independent read-only review passed. Bounded source probes validated 24h/48h/30-day requests and documented real contract drift without claiming full retention or continuity. CI remains required; deployed workflow validation follows the reviewed manual diff/deploy.

#2 — Expose bounded Protos reads through the DSS template @caina-barbosa  no labels

Adds a direct read-only adapter over the existing Protos MCP service using the DSS template foundation. Authenticated routes expose location IDs, four bounded dataset projections, and the 14-table metadata catalog; public discovery includes the seeded Dictionary with Enablement and an OpenAPI contract. Dataset queries use fixed allowlisted fields, validated dates/IDs and LIMIT n+1, with explicit truncation and window coverage.

The pinned official MCP client handles protocol plus vendor session initialization, memory-only token reuse/one renewal, bounded retries, JSON/SSE, cancellation and response limits. Reader output excludes personal/contact/narrative fields, financial amounts, media and precise coordinates. No public SQL/tool proxy or new data pipeline is introduced.

Validation: meaningful red/green source tests; independent code review passed after a tested timeout-to-504 correction; local checks/types/tests/build, dependency audit and synthesis passed. Live local HTTP checks returned 200 for all four dataset projections and traversed three location pages with 57 unique IDs matching the first-page total (source-key visibility only). Deployment and final deployed administrative smoke follow the existing manual release flow; no recipient keys are issued.

#1 — Configure isolated Protos deployment foundation @caina-barbosa  no labels

Reuse DSS-Template infrastructure with dedicated scoped roles and runtime secret injection, verified CentralFinance inputs, and a 960-second ALB read timeout. Local checks, infrastructure contract tests, build, and CDK synthesis pass. Source routes follow separately.

#1915 — fix(public-api): define enrollment schoolYear and student grade, and reject malformed years (AERIE-2433, AERIE-2434) @marcusdAIy  approved

## Summary

Defines the enrollment schoolYear as a four-digit starting year and documents the per-student grade on the enrollment student list. A malformed schoolYear such as 2027-28 on either enrollment route now returns 400 invalid_parameter, as capacity-outlook already does.

## Why it's needed

- AERIE-2433: listAdmissionsEnrollmentStudents serves a grade per student, but the dictionary never says which year that grade describes, so a reader counting 2027/28 seats by grade band can't trust it.

- AERIE-2434: the enrollments schoolYear had no stated form, and ?schoolYear=2027-28 came back as 200 unavailable with every metric at 0. That reads like a real empty year. Capacity-outlook refuses the same value with a 400.

## Changes

- chat/lib/public-api/v2/domains/admissions.ts

- Both enrollment routes (getAdmissionsProgramEnrollments and listAdmissionsEnrollmentStudents) now declare schoolYear with pattern ^\d{4}$ and describe it as the starting year (2026 means 2026/27). The router's existing parameter validation turns a mismatch into 400 invalid_parameter.

- The aggregate's schoolYear dictionary entry now states the starting-year form.

- The admissions.enrollmentStudent dictionary entry gets a grade field. The value is copied from enrollment_grade on the SIS enrollment row for the requested school year, so it is that year's grade, not the grade at application or entry. The entry also notes that values are SIS labels such as PK or 4, and that coordinate.grade is always null on this route. personObject takes optional extra fields so the other student kinds are unchanged.

- Tests:

- admissions.node.test.ts: checks the dictionary entries and the parameter schemas.

- convex/publicApi/v2/admissions.test.ts: checks that 2026-27 returns 400 on both routes.

## Breaking changes

A schoolYear that is not four digits now returns 400 instead of 200 unavailable. Before this change, such a value never matched a published year, so no caller was getting data from it.

## Test plan

- [x] pnpm vitest run convex/publicApi/v2/admissions.test.ts lib/public-api/v2 lib/dss: 183 passed

- [x] biome and typecheck-chat (pre-commit)

#1914 — docs(public-api): define the remaining SiteCompliance fields in the v2 dictionary (AERIE-2141) @marcusdAIy  approved

## Summary

Adds data-dictionary entries for the eleven SiteCompliance fields that #1908 left undocumented. All fourteen fields served by getSiteCompliance now have entries on portfolio.site.profile.

## Why it's needed

The compliance read had dictionary entries for only three of its fields (AERIE-2141). A reader couldn't tell a null inspectionStatus from the served toDo token, or a null zoning from unknown. Five enums had no legend, and nothing marked which fields hold people's names. #1908 covered foodPermitStatus, inspectionOutcome, and ahjReviewOutcome. This PR covers the rest.

## Changes

- chat/lib/public-api/v2/domains/site-profile.ts: entries for ahj, zoning, sevisOfficials, educationCounsel, inspectionDate, inspectionStatus, inspectionType, ahjReviewStatus, ahjReviewType, ahjReviewCount, and ftoCompleteDate.

- Each nullMeaning says that null is "not recorded" and is different from the explicit toDo and unknown tokens.

- The five enums each get enumValueMeanings. The traps add that complete does not mean passed or approved; that result lives in the outcome flags.

- sevisOfficials and educationCounsel say that they hold personal data and that an empty array means the same as null.

- Where Aerie doesn't define something, the entry says so instead of guessing: what "FTO" stands for, what ahjReviewCount counts, and whether inspectionDate is the scheduled or the actual date.

- site-profile.node.test.ts: new test that all fourteen SiteCompliance fields have entries and the six enums carry legends.

Out of scope: the ticket's sibling ask about the SiteDataSheet scalars, which is a separate object.

## Breaking changes

None. The change is to dictionary documentation only; response shapes are unchanged.

## Test plan

- [x] pnpm vitest run lib/public-api/v2: 137 passed

- [x] biome and typecheck-chat (pre-commit)

- [x] Merges cleanly with #1912 and #1913, which edit the same files

#1913 — docs(public-api): define legal-entity profile fields in the v2 dictionary (AERIE-2431) @marcusdAIy  approved

## Summary

Adds data-dictionary entries for the seven SiteLegalEntity fields served by getSiteLegalEntity (legalEntity, legalEntityAddress, dba, ein, legalEntityUpdatedDate, legalCounsel, legalCounselContactInfo) on portfolio.site.profile.

## Why it's needed

The legal-entity read had no dictionary entries (AERIE-2431). At 5201 Park Blvd, ein holds the owner's (Alpha Holdings) number. That can be correct for a disregarded single-member LLC, but nothing told a consumer whose number ein is or what null means.

## Changes

- chat/lib/public-api/v2/domains/site-profile.ts: one entry per legal-entity field, giving its meaning, what null means, and its traps.

- ein: Aerie does not record whose EIN it is, and for a disregarded entity it can be the parent's. The value is free text with no format check, and several sites can share one, so it is not a key. Marked restricted.

- legalEntityUpdatedDate: entered by hand, not stamped on write, so it can be older than the current values.

- legalEntityAddress: the entity's address, not necessarily the site's.

- For dba, ein, and legalCounsel, null means "not recorded", not "none exists".

- site-profile.node.test.ts: the dictionary test now checks that the seven entries are present.

## Breaking changes

None. The change is to dictionary documentation only; response shapes are unchanged.

## Test plan

- [x] pnpm vitest run lib/public-api/v2: 136 passed

- [x] biome and typecheck-chat (pre-commit)

#1912 — docs(public-api): add dictionary fields for facilities area, building count, and capacity asOf @marcusdAIy  approved

## Summary

Adds dictionary fields for the facilities values that had none (netFloorArea, netLearningArea, numberOfBuildings) and says what capacity.asOf means.

## Why it's needed

The facilities route serves these fields with no meaning, unit, or null semantics (AERIE-2463, AERIE-2469). Readers couldn't tell whether a netFloorArea of 14,967 is rentable, usable, or net area, or whether it covers the whole premises or only the school floor. They also couldn't tell what a building count of 10 is counting, or what the capacity.asOf date refers to. These are the last items AERIE-2469 left open after [#1907](https://github.com/AI-Builder-Team/Aerie/pull/1907) covered its due-diligence fields.

## Changes

- portfolio.site.profile (site-profile.ts), new fields sourced from SiteFacilities:

- netFloorArea (square feet). Two writers can set it: the Rhodes merger copies the ISP scan's main-building square footage (building.total_main_sqft), and people or API callers can patch it. Aerie records neither which one wrote the current value nor its area basis, so the trap says to confirm the basis before comparing. An incomplete ISP scan can report 0, which means unmeasured.

- netLearningArea (square feet). Entered by hand only. Null means not recorded, not that the site has no learning area.

- numberOfBuildings. Entered by hand only; Aerie has no rule for what counts as a building.

- capacity. asOf is the UTC date the server evaluated capacity for this read (new Date() at request time), not the date capacity last changed.

- portfolio.site (portfolio.ts): the same asOf trap on the existing capacity entry.

- site-profile.node.test.ts: the served dictionary includes the new fields.

The entries describe only what the code establishes. Where Aerie has no rule (area basis, what counts as a building), they say so rather than invent one.

## Breaking changes

None. These are dictionary metadata changes only.

## Test plan

- [x] pnpm vitest run lib/public-api convex/publicApi/dss: all pass except the pre-existing CRLF-only skill-package hash test on Windows

- [x] Pre-commit biome + typecheck-chat

#1911 — fix(finance): bound the Expense Analysis transactions query @marcusdAIy  approved

## Summary

The Expense Analysis dashboard now reads transactions through a bounded query. When a date range holds more transactions than one query can return, the tab shows a "narrow your range" message in place of the transactions table, rather than failing with a bare "Server Error".

## Why it's needed

The dashboard splits a date range into 119-day chunks and reads each one with getTransactions, which calls .collect() with no row ceiling (AERIE-2844). Convex rejects any query that scans more than 32,000 documents or returns an array longer than 8,192 elements. A busy chunk can trip either limit, and the browser then sees only a redacted "Server Error". Because the query runs inside ExpenseAnalysisView, the error replaced the whole tab, filter bar included, so the user couldn't even narrow the range.

## Changes

- convex/finance/dashboards/expenses.ts:

- New getBoundedTransactions. It scans at most 30,000 rows, which leaves headroom under the 32,000-document limit for the capability check. It returns { status: "ok", rows }, or { status: "too_large" } when either the scan or the filtered result (capped at 8,192) is over its limit.

- The over-length date-range guard now throws a ConvexError the client can read, not a redacted plain Error.

- The limit logic is a pure helper (boundDashboardTransactions) so it can be tested without inserting 30,000 rows.

- getTransactions is left unchanged so browsers still running the previous bundle keep working across the deploy. It can be removed in a follow-up.

- use-chunked-queries.ts: useChunkedTransactions calls the bounded query. A new combineTransactionChunks returns "too large" if any chunk is.

- expense-analysis-view.tsx: shows the message in place of the transactions table and keeps the filters and vendor spend on screen.

The limits match Convex's own, so a range that loads today never gets the new message. It only replaces failures that already happen.

## Breaking changes

None. getTransactions is unchanged; the dashboard switches to the new query.

## Test plan

- [x] pnpm vitest run convex/finance/dashboards/expenses.test.ts components/dashboards/expenses (14 passed)

- Unit tests for boundDashboardTransactions at both limits.

- getBoundedTransactions through convex-test: filtered rows come back, and an over-length range raises a ConvexError.

- combineTransactionChunks handles loading, auth, and too-large chunks.

- [x] tsc --noEmit for the convex tsconfig; pre-commit biome + typecheck-chat

- [ ] Open the Expense Analysis tab on a preview deploy and confirm normal ranges still load

#1910 — fix(public-api): capture unpublished event-contact snapshots as expected outcomes @marcusdAIy  approved

## Summary

Public API v2's event-contacts endpoint returns a 503 while an event's contact snapshot is still unpublished. That response is now captured as an expected outcome, not an unexpected Platform Error. The ambiguous-event-identity 503 shares the same code and is still captured as unexpected.

## Why it's needed

listAdmissionsEventContacts answers 503 event_contacts_unavailable with reason snapshot_publication_mismatch while a snapshot is unpublished. That is a normal, retryable readiness state, but the code wasn't in the readiness allowlist, so each occurrence landed in the Platform Error triage queue alongside real bugs (AERIE-2872).

The same code is also returned with reason ambiguous_event_identity. The API tells callers that retrying won't fix that case and to escalate it to the data owner, so it should keep reaching triage. That rules out allowlisting the code as a whole.

## Changes

- platformOutcomes.ts: publicApiV2PlatformOutcome accepts the error's reason. A new reason-scoped allowlist marks only event_contacts_unavailable + snapshot_publication_mismatch as expected_outcome.

- platformOutcomes.test.ts: covers the expected reason, the ambiguous reason, and a missing reason.

- admissions.test.ts: the existing event-contacts scenario now asserts the captured disposition. The unpublished snapshot is captured as expected_outcome; ambiguous identity is not.

## Breaking changes

None. The HTTP response is unchanged (same 503, code, reason, and retry fields); only the internal capture disposition changes.

## Test plan

- [x] pnpm vitest run convex/publicApi/v2/platformOutcomes.test.ts convex/publicApi/v2/admissions.test.ts convex/publicApi/v2/http.test.ts (81 passed)

- [x] Pre-commit biome + typecheck-chat

#2126 — fix(financials): persist reviewed QuickBooks account categories @ashwanth1109  approved

## Business Value

Persist the production-validated account-category repair that prevents campus revenue and expenses from dropping out of financial reporting. The reviewed delta adds 654 exact account mappings while preserving all 2,580 previous rows (3,234 mappings at the October 2 application, across 14 categories), including the 15 requested write-offs. Together with the separate School/coverage repair in PR #2124, the resulting financial publications pass revenue, expense and expected 20% Timeback reconciliation.

## Change

Add an apply-once, default-atomic extension of quickbooks-account-policy-v1, with matching read-only candidate/preflight queries and executed SQL boundary fixtures. It extends existing unanimous normalized account-name policy to wholly unmapped realms, and applies six explicitly user-accepted reporting-account families across realms. Named rules require the reviewed account number, exact normalized name and QuickBooks classification; source signs are preserved. Assumptions and provenance identify the user's approval without claiming separate Finance approval.

The caller pins the complete candidate count/fingerprint and accepted snapshot. The migration runs only as the existing warehouse owner, takes publication/policy locks, validates source keys and ledger acceptance, rejects competing rules, inactive categories, existing-identity collisions and marker drift, then appends atomically. It neither overwrites existing mappings nor automatically classifies future accounts. After application, drop the temporary procedure; replay with the old candidate pin fails rather than changing policy.

Also constrain the unmapped-actual publication audit to the generation represented by its current target, so retained historical ledger rows do not create a false failure. Document the exact application, supported Core invocation and live acceptance evidence beside the runner.

## Validation

- October 2 production application of the original revision completed: Data API statement e532aaf2-d36f-4e27-ba5b-2bec548e0d24 FINISHED, reviewed candidate count 654, fingerprint b19ca992a620ffa88eb2872854593c22, accepted source 86a0026e-71de-4df4-86f5-869380527dbc. All previous full rows preserved and all added identities/categories exactly matched the reviewed candidates. Temporary procedure removed afterward.

- The original October 2 procedure statement in 3ca4008e exactly matched the applied DDL request; migration file SHA256 fbd7cdc3fcf5275725519ca7034f59bdbdd20510b3dfcb8e481ff5e7e41962ef.

- Genuine supported Core ON_DEMAND core-fast-financial-validation-20261002-105212 SUCCEEDED in 4m 9s, reusing accepted raw snapshot 6cad5602-4634-4681-9b31-d926b29b33d4; posting generation fe6f4160-39f5-4116-b2cd-4b9b052a9e65:posting.

- The automatically triggered mart timed out behind unrelated HubSpot/financial-writer locks. After clearance and fresh preflight, supported report-only retry financial-validation-lock-retry-20261002-111257 SUCCEEDED in 2m 26s. Elapsed Core start to accepted report was 23m 11s including contention/recovery; 6m 35s is the sum of successful processing stages, not a clean-chain benchmark. No new 68-company extraction or duplicate Core execution was started.

- All 24 Core financial/publication audits and coordinated category, revenue and 20% Timeback checks PASS. The coordinated School/coverage candidate produces 59 Schools in all three QTD financial contracts and zero Facilities GL/total/shape/lineage mismatches; that separate source change remains in PR #2124.

- All 143 local Core tests PASS, including 36 account-policy cases. They execute the canonical ledger/parity predicates and cover older receipts hiding newer replay, unknown publication times, deletion of the last represented extraction row, unrelated-table receipts, and SQL NULL truth rules. Scoped Ruff and git diff --check PASS.

- All 46 isolated Redshift procedure cases PASS on 2026-10-15 for revision 2026-10-15.1. The candidate procedure is compiled and executed against uniquely named sandbox objects; tests exercise replay boundaries, exact persisted mapping tuples, preservation of original rows, canonical NOT NULL constraints, deliberately nullable legacy marker copies, and post-write rollback. Production policy was not reapplied, and fixture cleanup was catalog-verified. The original October 7 revision also passed its 32 live cases.

The live account delta is already applied. Source merge must not replay the old reviewed migration against production. CI reruns for review-fix commit 7f8aa632; source review remains required before merge. Future automatic onboarding/classification is outside this reviewed apply-once delta.

## Review verification

- The October 7 proposal to require one extraction ID for every retained row remains withdrawn: accepted incremental current-state tables contain multiple extraction IDs.

- The valid replay finding is fixed in 7f8aa632: both input tables' latest publication receipts must be covered by the accepted snapshot. Successful receipts remain required for represented extraction IDs. The table-level boundary also catches replay deletion of every row carrying an extraction ID, a gap independently reproduced for both inputs in Redshift. Accepted older rows and unrelated-table receipts retain their supported behavior.

- Production catalog metadata confirms mapping_count and category_count are NOT NULL. Defensive pre/post marker guards now explicitly reject NULL or missing metadata and verify both row and category parity. Actual Redshift tests cover normal constraints, nullable sandbox copies, and rollback after both writes.

- The success-path test asserts exact persisted Account/category/source-key tuples, every original xref column, and every marker field except the intended count increment.

The October 2 application remains historical evidence for the original 654-row delta. The current guard-only migration revision has been live-validated on isolated sandbox fixtures; routine pipeline code is unchanged and the production delta is not replayed.

## Implementation Effort

Approximately 2–3 engineer days to inspect account identities and existing policy, prepare guarded migration/review queries, exercise failure boundaries, apply and validate the candidate, reconcile published categories/revenue/Timeback and document the rollout. Pipeline waiting is additional.

## Linear

Related [SURTR-1497](https://linear.app/builder-team/issue/SURTR-1497/extend-quickbooks-account-categories-to-new-realms-by-exact-chart-of), under [SURTR-1496](https://linear.app/builder-team/issue/SURTR-1496/close-quickbooks-campus-pandl-gaps-from-finance-escalation-quickbooks). This PR persists the reviewed October repair; SURTR-1497's broader automatic future-realm classification is not implemented here.

#21 — feat(infra): move Rhodes DSS to rhodes-dss.com, keep rhodes-dss.klair.ai working @kevalshahtrilogy  no labels

## Summary

- New optional stack parameter AlternateCertificateArn. When set, it is attached to the HTTPS listener as an extra SNI certificate (AWS::ElasticLoadBalancingV2::ListenerCertificate, conditional on non-empty). The ALB does not route by host, so both hostnames are served the same way.

- scripts/release.mjs accepts the parameter as optional and checks that it is an ACM ARN.

- scripts/provision-deployment.mjs grants the deploy role Add/Remove/DescribeListenerCertificates. This needs a one-time --update by the administrator before the deploy.

- The dictionary's links and the README now point at https://rhodes-dss.com. The front, OpenAPI and enablement already come from PublicOrigin.

- New infrastructure test for the parameter and the conditional listener certificate.

## Rollout (outside this repo)

1. Issue an ACM certificate for rhodes-dss.com in the stack's region. Add its DNS-validation CNAME through Service Desk using Add New DNS Record.

2. Point rhodes-dss.com at the ALB through Service Desk using Create DNS Record - Alias. Leave the rhodes-dss.klair.ai record in place.

3. Run node scripts/provision-deployment.mjs --update.

4. Update STACK_PARAMETERS: PublicOrigin=https://rhodes-dss.com, CertificateArn=<new cert>, AlternateCertificateArn=<current klair.ai cert>. Run diff, then deploy.

## Business Value

Rhodes DSS moves to its own domain without breaking anything. Agents and users who already have rhodes-dss.klair.ai keep working, and everything new is published at rhodes-dss.com.

## Manual Effort Estimate

About 2 hours. This is a proposed number: Keval, please confirm or adjust.

## Test plan

- [x] pnpm check (50 tests)

- [x] pnpm build

- [x] pnpm synth --no-lookups: the template has the parameter, the condition and the listener certificate

- [ ] After deploy: both https://rhodes-dss.com/api/skill and https://rhodes-dss.klair.ai/api/skill return 200

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#2166 — Fix Master Mapping raw COPY stage defaults @sanketghia  approved

## Summary

- Preserve default expressions when creating the temporary raw-observation COPY table by using LIKE ... INCLUDING DEFAULTS.

- Add a regression test verifying the raw COPY stage inherits the _loaded_at default while that warehouse-managed column remains omitted from the source COPY column list.

## Failure addressed

staging_finance_gsheets.raw_master_mapping._loaded_at is NOT NULL DEFAULT GETDATE(). Redshift CREATE TABLE ... LIKE excludes defaults unless INCLUDING DEFAULTS is specified. The preview's COPY stage therefore had a required column without a default, and the COPY failed with NOT NULL column without DEFAULT must be included in column list.

## Validation

- ./.venv/bin/pytest tests -q — 97 passed.

- git diff --check — clean.

- The regression test failed before the SQL change and passed afterward.

- A local run of the patched handler completed in preview mode (run ID 3d42a075-18ce-4d9e-9e01-735ba5c6ebe2): 623 projected source rows matched 623 target rows, with 0 new, removed, or changed rows. It published 624 raw grid rows and a successful ingestion-ledger entry; temporary COPY objects were cleaned up. The compatibility table fingerprint remained unchanged.

- That direct local invocation wrote versioned S3 evidence and raw/ledger data but did not create a Surtr run record, so it is diagnostic and cannot serve as a UI publish preview.

- Ruff was unavailable in the runner virtual environment.

## Deployment

No Redshift DDL change is required. The Lambda code must be deployed before a subsequent Surtr UI run will use this fix.

#2163 — Add manual Master Mapping sync and guarded local writer @sanketghia  changes requested

## Summary

- Add the manual-only master-mapping-sync Surtr pipeline for the Finance Master Mapping Google Sheet. It retains the full Sheets response as versioned S3 evidence, projects the ten compatibility fields to raw staging, and records captured runs in the shared ingestion ledger.

- Add duplicate-preserving full-row multiset preview, owner-gated publish, and owner-gated restore flows. Publish replays the exact pinned preview, checks target drift, snapshots the prior publication, atomically replaces the compatibility table, and verifies row count and full-row multiset.

- Keep the existing staging_gsheets.master_mapping_enriched compatibility contract for Surtr and Klair readers.

- Add a local dry-by-default preview report and an explicit compatibility-table-only --write path. The local writer requires a pinned workbook/report, approval reference/reason, and result-report path. It verifies the target schema and baseline, locks the target, creates a collision-checked staging_gsheets__master_mapping_enriched_bak_YYYYMMDD_HHMMSS backup, and performs transactional DELETE plus parameterized INSERT with full-row verification. It does not write S3 evidence, raw rows, the ingestion ledger, or Surtr run records.

- Sync the feature branch with the latest origin/main.

## Data and operational notes

- The reviewed local dry preview found 623 source rows against a 620-row target: three additions (EDU AI Builders, EDU CNU, and EDU WS Engineering), with no removals or changed rows. Duplicate groups, including both CF Order adjustment class rows, are preserved.

- The owner-directed local compatibility-only write was performed and independently verified: the target matches the 623-row source multiset, and the pre-write 620-row multiset is retained in a sandbox backup. No S3/raw/ledger/Surtr run records were written by that local write.

- 002_master_mapping_raw.sql was applied. The shared ingestion ledger already exists, so no ledger migration is needed. Repeatable table DDL no longer contains unconditional COMMENT ON statements; the one-time metadata script omits the richer shared-ledger comment. The compatibility table already existed and its comment was set separately. No deployment, scheduled run, or Surtr pipeline invocation is performed by this PR.

## Review disposition

- Addressed: restrict s3:DeleteObject to temporary COPY staging; persist a write-intent receipt before connecting and avoid a retry-signalling exit after a confirmed commit; suppress cursor-close exceptions from overriding committed status; validate local CSV and pinned-report shape; admit only rows with normalized published values; handle GridData column offsets and missing formula results; validate restore row types and target schema/grain; normalize duplicate diagnostics and keep blank-class additions/removals separate; pin publish to the reviewed sheet title; reconcile uncertain Data API outcomes using the atomic ledger.

- Not changed: the production owner check receives triggered_by from Surtr's server-side authenticated trigger context; the public trigger input cannot set that field, and direct AWS invocation remains under IAM control. The local writer remains the previously approved owner-operated exception: its approval reference/reason are audit metadata, while local credential access is the write boundary. The docs now state that these strings are not independent authentication.

## Verification

- uv run --extra dev pytest -q in pipelines/runners/master-mapping-sync: 66 passed.

- Ruff check/format, uv lock --check, and git diff --check: passed.

- CDK TypeScript build and the real-pipeline-configs Jest suite: passed (620 tests in the latter).

- Full CDK Jest reported 988 passing and 7 failing in two Python-bundling suites because Docker Desktop was not running and bundling could not connect to the local Docker socket.

#2164 — feat: implement Q22 company attribution policy @sanketghia  changes requested

## Summary

- Resolve school-owned QuickBooks Q22 lines through the exact current company xref; route the two central realms through effective-dated class policy.

- Apply the same Q22 policy to Fixed Asset and 62700 detail while preserving shared Core semantics, line identity, signed amounts, and explicit residuals.

- Add fail-closed company/class mapping validation and a SELECT-only detail-to-aggregate reconciliation.

- Align the local preflight with central-file rules and document verification.

## Verification

- uv run --project pipelines/runners/mart-education-q22-lifetime-capex-refresh --group dev pytest pipelines/runners/mart-education-q22-lifetime-capex-refresh/tests — 50 passed.

- git diff --check — passed.

#3871 — feat(performance-review): load BU catalog from master mapping @sanketghia  approved

## Summary

- Add a permission-aware Performance Review BU catalog endpoint backed by the synced Redshift BU context view.

- Build the sidebar's BU options and entity types from that endpoint instead of the frontend BU-name arrays.

- Validate class lookups and generic Performance Review filters against the user's assigned BU names, without requiring enum registration for each mapped BU.

## Catalog behavior

The catalog includes active mapped BUs, keeps the existing Test/Totogi exclusions, and omits parent-only Education mappings while retaining Other reporting buckets without leaf classes.

The current mapping view differs from the former frontend registry: it adds six selectable names (BloomTech, Cosi, OTHER, Other Miscellaneous, Servco, and Trilogy Japan). Eight names from the former list are absent from the active mapping view; a read-only check found no rows for them in core_budgets.consolidated_budgets_and_actuals.

## Validation

- Ruff format check and Ruff check passed.

- Python syntax compilation passed.

- Prettier, ESLint, and TypeScript checks passed.

- Pyright reports 30 errors and 33 warnings in the existing income_statement.py module; no diagnostics were reported in the changed sections.

- Test suites were not run.

#3869 — feat(master-mapping): register new Education BUs @sanketghia  approved

## Summary

- Register EDU CNU, EDU WS Engineering, and EDU AI Builders as Education business units in the frontend and backend registries.

- Keep the Monthly Financial Reporting Education partition aligned with the backend EDUCATION_SET.

- Extend frontend and backend registry coverage for the three units.

## Context

These are leaf Education rows 622–624 in the [Master Mapping sheet](https://docs.google.com/spreadsheets/d/1RoARNoOzebrSN1G4qmE91wYvEhTKju5JySi57czdeOQ/edit?gid=0#gid=0). The mapping sync is already reflected in staging_gsheets.master_mapping_enriched; this PR changes application registries only. The current consolidated budgets-and-actuals table has no rows for these units yet.

## Validation

- uv run ruff format models/income_statement_models.py services/monthly_financial_reporting/financial_data_service.py tests/test_business_unit_sync.py — unchanged.

- uv run ruff check models/income_statement_models.py services/monthly_financial_reporting/financial_data_service.py tests/test_business_unit_sync.py — passed.

- uv run pyright models/income_statement_models.py services/monthly_financial_reporting/financial_data_service.py tests/test_business_unit_sync.py — 0 errors.

- pnpm prettier --check src/constants/businessUnits.ts src/constants/businessUnits.spec.ts — passed.

- pnpm exec eslint --max-warnings 0 --no-warn-ignored src/constants/businessUnits.ts src/constants/businessUnits.spec.ts — passed.

- pnpm tsc -p tsconfig.app.json --noEmit — passed.

- git diff --check — passed.

- Test suites were not run.

## Screenshot

<img width="671" height="636" alt="image" src="https://github.com/user-attachments/assets/edc016fd-b1cf-4d73-be6a-a6193bf8256b" />

#1884 — Events report: switch consumers to direct HubSpot mart @vvp-trilogy  approved

## Summary

- derive event aggregates and deterministic participant rollups from sandbox_education.mart_admissions_marketing_event_dtl

- keep aggregate/contact publication atomic while pinning both event reads to direct Redshift until matching Gateway publications exist

- remove guest-inclusive, rejected, and current-student measures from active dashboard/API contracts; retain historical Convex fields as optional

- document participant and attendee-derived outcome semantics for API/agent consumers

## Validation

- pnpm --filter @bran/contracts test -- admissions-marketing-events.test.ts

- pnpm --filter @bran/contracts typecheck

- pnpm --filter @bran/sync typecheck

- focused sync query/refresh/gateway tests (219 passed)

- focused Events UI + Convex dashboard tests (107 passed)

- pnpm --dir chat typecheck

- pnpm lint:test-architecture

- pnpm lint:read-bounds

- Biome checks and pre-commit checks

## Warehouse reconciliation

Compared direct-mart consumer semantics against the current legacy event detail consumer at Program + event ID grain (1,756 combined event keys):

| Metric | Legacy | Direct | Delta |

|---|---:|---:|---:|

| Registered | 53,831 | 52,784 | -1.9% |

| Attended | 20,556 | 19,943 | -3.0% |

| No-show | 29,903 | 29,609 | -1.0% |

| Applied | 3,395 | 2,082 | -38.7% |

| Shadowed | 4,325 | 1,478 | -65.8% |

| Enrolled | 4,134 | 1,625 | -60.7% |

Participation measures are closely aligned. Outcome reductions are expected from the intentional contract change: the direct mart exposes only attendee-derived post-event outcomes and counts child/deal outcome identities rather than the legacy overloaded contact state. The direct source also has 303 event keys absent from legacy and legacy has 93 absent from direct.

## Rollout

- No post-deployment cleanup script.

- PR intentionally remains unmerged pending review and stakeholder acceptance of reconciliation.

Closes #1870

#3 — Fix observed Zello channel detail response shape @caina-barbosa  no labels

Single-channel reads returned 502 because Zello exposes channels as a direct channel object, while the client expected a keyed collection or array. Accept the observed direct object and preserve existing fallback formats; no source permissions or mutation surface change.

Validation: the live shape reproduced in a failing regression; all 14 client tests, final CI (45 tests) and independent focused review pass. The deployed endpoint now returns HTTP 200 with required member fields; roles also pass. Positive guard local-day/UTC agreement and complete membership response pagination pass. Temporary validation keys revoked. Final release: https://github.com/AI-Builder-Team/Zello-DSS/actions/runs/38649638718 .

#2 — Deliver Zello datasets and dictionary workflows @caina-barbosa  no labels

Deliver the five Zello Dictionary datasets and all nine enablement workflows through the existing authenticated DSS foundation. A fixed read-only HTTPS client uses HMAC sessions, complete bounded pagination, overlapping history windows and ID deduplication; serial reads respect upstream 429/Retry-After. Emergency derivation reports incomplete response/resolution windows explicitly; local-day queries require timezone or UTC bounds.

Direct-message text/transcription are omitted as permitted by the Dictionary. Other restricted source fields remain behind authorized DSS keys; no media, mutation, location-history, pipeline or source-data storage is added. Publish matching OpenAPI and working enablement examples.

Validation: independent acceptance/security/test-quality review passed; final CI passed 45 tests, dependency audit, build, synthesis, Docker and history scanning. Deployed HTTPS checks covered all five datasets and nine workflows, complete message/membership pagination, positive guard local-day/UTC agreement, private-content omission and key revocation. The observed channel-detail envelope was repaired in PR 3 and retested live. Final release: https://github.com/AI-Builder-Team/Zello-DSS/actions/runs/38649638718 . Numeric sustained source quota remains undocumented; representative bounded requests succeeded without 429/rate headers. No recipient keys issued; all temporary keys revoked.

#2 — Expose read-only UniFi inventory and Protect camera metadata @caina-barbosa  no labels

Adds authenticated direct Site Manager inventory and Protect camera metadata reads with a public OpenAPI contract. Uses fixed read-only routes, explicit field allowlists, source-native camera model/last-boot joins, verified pageSize500 pagination and false-zero guards; shared per-console throttling preserves upstream Retry-After.

The complete supplied Dictionary remains unchanged. Enablement identifies Access diagnostic403/local access limitations, untested event/queue producers and missing health history. No generic proxy, capabilities endpoint, source writes, media or new pipeline.

Validation: 32 tests pass (23 source cases), one existing setup test skipped; typecheck/build and high/critical audit pass. Behavior red/green evidence includes repeated tokens, false-zero devices, missing required metadata, scalar blob rejection, partial joins and Retry-After300 previously shortened to60. Independent review passed after focused fixes. Live local wrapper returned13hosts7sites16groups184devices and camera12+9 with joins/version, honest missingtimestamps; auth/feedback/operator/revocation passed, temporary keys revoked. Deployed HTTPS source validation follows manual release after foundation.

#1 — Configure isolated Zello DSS foundation @caina-barbosa  no labels

Create the independent Zello DSS foundation from DSS-Template: source dictionary/discovery, hashed caller keys, private feedback, container and dedicated Central Finance release resources. Runtime credentials are injected from an isolated Secrets Manager ARN; CI receives no source secrets. Source data routes follow in the next change.

Validation: foundation tests, infrastructure safety assertions and CDK synthesis passed; independent security review found no blocking issues. Deploy uses the existing scoped OIDC/manual diff-then-deploy workflow.

#1 — Configure isolated UniFi Security DSS foundation @caina-barbosa  no labels

Creates an independent UniFi Security DSS from DSS-Template, retaining the complete supplied Dictionary and exposing the existing documents, hashed key registry and authenticated feedback foundation. Adds dedicated Secrets Manager injection and service-scoped ECS/HTTPS infrastructure with immutable GitHub OIDC manual releases.

Validation: foundation, feedback-operator and infrastructure tests passed; TypeScript build and credential-free CDK synthesis passed. Source inventory/camera implementation follows in a separate change; unavailable door/event/history workflows remain explicit.

#1908 — docs(public-api): add dictionary fields for compliance outcomes and buildout phase facts @marcusdAIy  approved

## Summary

Adds agent-context dictionary fields for three compliance facts (ahjReviewOutcome, foodPermitStatus, inspectionOutcome) and the three buildout phase fields that are still editable (furnishingBeginDate, finalInspectionPassedDate, capacityBuilt).

## Why it's needed

The API serves all six fields, but the dictionary had no entry for any of them, so readers couldn't confirm their meaning or allowed values (AERIE-2590). The ticket points to ahjReviewOutcome in particular: it is a true/false/null flag, but nothing says what true means.

## Changes

- portfolio.site.profile (site-profile.ts):

- foodPermitStatus with meanings for notStarted, pending, and done.

- inspectionOutcome and ahjReviewOutcome, each tied to the inspection or review its sibling fields describe.

- Traps state what the data doesn't establish: Aerie defines no meaning for true beyond the field label, the flag is set independently of the status field, and only one value is kept per site.

- portfolio.buildoutPlan (lifecycle-property.ts):

- furnishingBeginDate and finalInspectionPassedDate, including the N/A value and how it differs from null.

- capacityBuilt, with the rule that a phase counts toward current capacity only once it is completed, and that this is not permitted capacity.

- Tests: the served DSS dictionary carries the compliance fields and the food-permit enum, and the buildout plan documents all three phase fields.

## Breaking changes

None. These are dictionary metadata changes only; API responses are unchanged.

## Test plan

- [x] pnpm vitest run lib/public-api convex/publicApi/dss: all pass except the pre-existing CRLF-only skill-package hash test on Windows

- [x] Catalog validation passes, including the N/A enum-meaning check on the two dates

- [x] Pre-commit biome + typecheck-chat

#1907 — docs(public-api): give every served due-diligence key a dictionary field @marcusdAIy  approved

## Summary

Every key the API serves on PropertyDueDiligence now has an agent-context dictionary field. Eighteen keys had none, including status, the open dates, zoning, occupancy, mode, and site square footage.

## Why it's needed

Agents reading due diligence through the API got these values with no stated meaning, enum definitions, or validation rules, so they guessed. For example, they had no way to know that status values other than complete can still change, or that siteSquareFootageConfirmed is the denominator for every per-square-foot CapEx metric (AERIE-2692; also covers the due-diligence key gaps listed in AERIE-2469).

## Changes

- operations.propertyDueDiligence in chat/lib/public-api/v2/domains/property.ts: added entries for status, dateCompleted, the six phase info-outstanding fields, phase1OpenDate / phase2OpenDate, zoningStatusConfirmed / zoningStatusComment, currentOccupancyConfirmed, the phase mode and occupancy fields, and siteSquareFootageConfirmed.

- Enum fields carry enumValueMeanings from the contract values and editor labels.

- Invariants cite the rules in packages/contracts/src/due-diligence.ts, such as Phase 2 opening on or after Phase 1, the regulatory-score-1 requirements, and when a zoning comment is required.

- Traps cover what the data can't show: diligence dates and zoning are point-in-time values, not the live portfolio values; legacy zoning text lives in the comment; dateCompleted isn't date-validated; and the square footage's area basis isn't recorded.

- property.node.test.ts: a new test fails if any served PropertyDueDiligence key lacks a dictionary field.

## Breaking changes

None. These are dictionary metadata changes only; API responses are unchanged.

## Test plan

- [x] pnpm vitest run lib/public-api: all pass except the pre-existing CRLF-only skill-package hash test on Windows

- [x] New coverage test failed before the entries were added (18 missing keys) and passes after

- [x] Pre-commit biome + typecheck-chat

#1905 — docs(public-api): name the source and fields of finance.expenseAnalysis @marcusdAIy  approved

## Summary

The finance.expenseAnalysis dictionary entry now names its source system (each accounting entity's QuickBooks company, read through the Education data warehouse; not NetSuite) and its request parameters. It also has a field entry for every key the two expense operations return, including the vendor identity. Fixes [AERIE-2667](https://linear.app/builder-team/issue/AERIE-2667) and [AERIE-2567](https://linear.app/builder-team/issue/AERIE-2567).

## Why it's needed

The entry described a "synchronized" vendor aggregate without saying where it was synchronized from or which field carries the vendor. It listed three of the twelve response keys and none of the request parameters, so a reader had to learn the call shape from 400 errors (AERIE-2567).

## Changes

- chat/lib/public-api/v2/domains/finance.ts, finance.expenseAnalysis:

- identity names the request parameters: accountingEntityKey, periodCode or startDate + endDate (at most 120 days), schoolNames, accountNames, and for transactions searchTerm, limit, and cursor.

- lifecycle names QuickBooks purchase lines as the source, says NetSuite isn't one, and says no public route reads the QuickBooks vendor record itself.

- New field entries: grain, currency, vendors (whole collection in one response; transactions is the paginated route), vendor, spend, lineCount, collectionTotalSpend, date, vendorName, amount, accountName, schoolName (the QuickBooks class), category, memo, and source. Each has its genesis, nullability, and sensitivity; vendorName and memo stay restricted.

- chat/lib/public-api/v2/domains/finance.node.test.ts: the served entry names QuickBooks and the request parameters, and has a field for every property of the four expense response schemas.

## Breaking changes

None. Dictionary text only; the API responses are unchanged.

## Test plan

- [x] chat: npx vitest run lib/public-api convex/publicApi/agentContext/http.test.ts convex/publicApi/routeManifest.test.ts convex/publicApi/v2/domains/finance.test.ts. All pass except skill-package.node.test.ts, which fails the same way on main in a Windows CRLF checkout.

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

#1903 — fix(rhodes): refuse document Drive links the service account can't open @marcusdAIy  approved

## Summary

The Rhodes registerDocument and updateDocument tools now look up a new Drive link through Aerie's Drive service account before proposing the write. A Drive ID that isn't found, is in the trash, or isn't a Drive ID at all is refused with a message saying what to fix, before any approval is created. This covers item (a) of [AERIE-2585](https://linear.app/builder-team/issue/AERIE-2585).

## Why it's needed

AERIE-2585 found a registered row whose Drive ID had lost one character (…MOuOy… instead of …MOIuOy…), so every consumer's open and download calls failed. These two tools are where an agent types a Drive ID by hand, and nothing checked that the ID existed. Uploads and Global managed-Drive registrations already get their IDs from Drive, so they aren't affected.

Document indexing reads through the same service account, so a file it can't open could never be opened or indexed by Aerie either. Refusing it at write time doesn't block any row that would have worked.

## Changes

- chat/rhodes-worker/mcp-server/tools/documents.ts:

- Add assertDocumentDriveFileReadable. It takes the explicit driveFileId, or the ID in driveUrl, and calls Drive files.get.

- A non-ID string, a driveUrl with no Drive file ID in it, a Drive 404, or a trashed file throws a clear error. Any other Drive error is passed through unchanged.

- registerDocument and updateDocument call it before classification and approval. It's skipped when no Drive link is being set, or when Drive isn't configured.

- chat/rhodes-worker/src/index.ts: pass the service-account token getter to registerDocumentTools.

- chat/rhodes-worker/mcp-server/tools/documents.test.ts:

- A missing ID (direct and through driveUrl), a trashed file, and a malformed ID are each refused before any Convex call.

- An update that doesn't set a Drive link never calls Drive and still reaches approval.

## Breaking changes

None for valid links. A registration or update that names a missing, trashed, or unshared Drive file now fails at the tool call instead of being approved.

Items (b) through (d) of AERIE-2585 (metadata provenance, content-hash dedup, and signature checks for titles) aren't in this PR.

## Test plan

- [x] chat/rhodes-worker: npx tsx --test mcp-server/tools/documents.test.ts mcp-server/tools/portfolioParity.test.ts (17 pass)

- [x] tsc --noEmit -p rhodes-worker/tsconfig.json

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

#1932 — feat(education): activate Marauder's Map ingestion @benji-bizzell  approved

## Summary

- Activate all four Marauder's Map ingestion schedules with staggered starts and bounded concurrency

- Keep on-demand execution gated until Surtr has an explicit operator authorization boundary

- Record resolved observed scopes in the warehouse ledger, including zero-row daily coverage

## Why

PR #1926 delivered and validated the governed raw-ingestion pipeline but retained every production schedule gate. The merged pipeline therefore could not refresh data automatically. This follow-on activates the scheduled path and adds the operational controls identified during activation review.

## Business Value

Marauder's Map observations will refresh automatically at the intended incident, inventory, and daily-report cadences. The activation avoids normal schedule collisions, limits source and warehouse load during degradation, and preserves queryable evidence for empty daily observations.

## Test plan

- [x] 44 Marauder's Map runner tests pass

- [x] Ruff lint and formatting checks pass

- [x] 656 relevant CDK tests pass

- [x] TypeScript build passes

- [x] Seven-lane adversarial activation review completed and findings addressed

- [ ] Deploy and verify the four production EventBridge rules are enabled

- [ ] Verify a fresh scheduled execution publishes reconciled warehouse observations

#1902 — fix(public-api): name the cause of a rejected document upload @marcusdAIy  approved

## Summary

Document uploads that the Rhodes worker rejects for a known cause now return 422 document_upload_rejected with a stable reason and a message saying what is wrong. The known causes are site_drive_folder_missing, site_drive_subfolder_missing, and delegated_actor_unresolved. The upload 409 docs also now say how an Idempotency-Key behaves after a rejection. Refs [AERIE-2366](https://linear.app/builder-team/issue/AERIE-2366) and [AERIE-2560](https://linear.app/builder-team/issue/AERIE-2560).

## Why it's needed

Aerie maps every worker 4xx except 401/403 to the same 422 and drops the worker's message. So a key owner with no identity binding, a site with no Drive folder, and a missing document-type subfolder all look identical to the caller. That made the AERIE-2366 failures under JC's key impossible to diagnose from the response.

The worker's raw message isn't passed through because it can name the site or the actor's email. Only a reason from a fixed list is forwarded, with Aerie-authored text.

AERIE-2560 asked what a caller should do after a 422. The intent stays bound to its first request, so changing any input under the same key returns 409. The docs now say to retry unchanged with the same key, or use a new key for changed input.

This PR doesn't fix the underlying data: JC's key-owner binding (AERIE-2366) or the Laguna Hills site that rejects every upload (AERIE-2560). Once deployed, the reason on the next rejection says which fix each needs.

## Changes

- packages/contracts/src/document-upload-rejection.ts (new): the reason list, a type guard, and the public message for each reason. Exported as @bran/contracts/document-upload-rejection.

- chat/rhodes-worker/src/document-upload-rejection.ts (new): DocumentUploadRejectionError, plus documentUploadRejectionReason, which finds the reason through contextualizeDriveOperationError's cause wrappers.

- chat/rhodes-worker/src/index.ts: requireDriveFolderId and resolveFolderPath throw the typed error. Their messages and statuses are unchanged. The Aerie mutation route adds reason to its error body when one is found.

- chat/rhodes-worker/src/delegated-convex-auth.ts: an unresolved delegated actor throws the typed error.

- chat/convex/publicApi/v2/domains/documents.ts: a worker 4xx with a known reason becomes a 422 with that reason, the contract message, retryable: false, and escalation guidance. Any other 4xx keeps the generic rejection.

- chat/lib/public-api/v2/domains/documents.ts: the Site and Global upload operations document the 422 reasons and the 409 idempotency behavior.

- Tests: a known reason is forwarded without the worker's message; an unknown reason and a reasonless 4xx stay generic; the worker finds the reason through wrapped and cyclic cause chains; the delegated-actor error carries its reason.

## Breaking changes

None. Status codes and code values are unchanged. reason is a new optional field on an existing problem response, and the detail text is more specific for the three known causes.

## Test plan

- [x] chat: npx vitest run convex/publicApi/v2/documentsHttp.test.ts (48 pass)

- [x] chat/rhodes-worker: npx tsx --test src/document-upload-rejection.test.ts src/delegated-convex-auth.test.ts (4 pass)

- [x] tsc --noEmit for chat, convex, and rhodes-worker

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

#1901 — dbt: remove legacy EduCRM source package and Pipeline crosswalk reads @vvp-trilogy  approved

## Summary

- remove the final legacy EduCRM dbt source and obsolete reconciliation analyses

- publish canonical Program identity as mart_program_identity

- resolve QTD and sync Pipeline crosswalks through that published mart, preserving fail-closed missing-tenant behavior

## Validation

- pnpm --dir sync exec vitest run src/analytics/queries/admissions-pipeline.test.ts --maxWorkers=1

- pnpm --dir sync exec vitest run src/analytics/queries/admissions-pipeline-gateway.test.ts --maxWorkers=1

- pnpm --dir chat exec vitest run convex/finance/dashboards/financialLive.test.ts --maxWorkers=1

- pnpm test:root

- pnpm --dir sync typecheck

- pnpm --dir chat typecheck

- pnpm lint

- poetry run dbt parse --no-partial-parse; manifest contains zero educrm sources

- dbt ls --select mart_program_identity+; selects the mart and its grain/identity tests

## Warehouse comparison

- legacy Pipeline crosswalk: 57 distinct tenant/program pairs

- canonical identity: 60 pairs, 60 tenants, 60 program codes; no duplicate tenant or Program mappings

- 56 pairs shared; canonical adds four governed mappings absent from Pipeline activity

- Alpha Las Vegas is intentionally omitted because canonical identity has no governed Finalsite tenant binding; tenant-required consumers continue to fail closed

Closes #1880

#1900 — Fix forecast total equation wrapping @vvp-trilogy  approved

## Summary

- wrap the three forecast-total equation terms independently

- keep each operator attached to the term that follows it

- allow the final forecast value to move onto the next line instead of overlapping the level breakdown

## Validation

- pnpm --dir chat exec vitest run components/dashboards/admissions/forecast/v2/__tests__/forecast-v2-report.test.tsx --maxWorkers=1

- pnpm --dir chat exec biome check components/dashboards/admissions/forecast/v2/forecast-v2-tabs.tsx

- pnpm --dir chat typecheck

#1891 — dbt: own Forecast conversion rates from typed HubSpot facts @vvp-trilogy  approved

## Summary

- derive Forecast application, shadow, and offer conversion rates directly from accepted typed HubSpot Enrollments, associations, sessions, Programs, contacts, and stage definitions

- preserve the inclusive trailing 210-to-30-day application cohort, deal-grain backward stage inference, 10-deal sample gate, network pooled fallback, and three-target-year contract

- remove the coming-year projection staging bridge and publish accurate Aerie-owned algorithm provenance

## Validation

- poetry run dbt parse --no-partial-parse with parse-only profile placeholders

- deterministic unit fixture covers both window boundaries, backward stage inference, sample-size fallback, and target-year expansion

- read-only finance_dw comparison against current Production latest projection rates; full prefixed dbt build/test will provide authoritative PR-model comparison evidence

## Safety

- reads accepted typed staging_education_hubspot publications only through Aerie staging models

- does not introduce staging_education.hubspot_*, biz_lead_funnel, or Due Diligence writeback

Closes #1879

#1897 — fix(public-api): read v1 expense vendors page by page instead of past Convex's read limit @marcusdAIy  approved

## Summary

GET /v1/financials/expenses/vendors now reads its date window in bounded pages and aggregates vendor spend in the HTTP action. Before, it read every row in one query and failed with Convex's "Too many documents read (limit: 32000)" 500. The response shape is unchanged. Fixes [AERIE-2869](https://linear.app/builder-team/issue/AERIE-2869).

## Why it's needed

The route backed onto getVendorSpendForPeriodForApi, which collect()ed every expenseTransactions row in the window. The route allows up to 4 months, and prod data exceeds the read limit well within that. Read-only counts for companyId: "alpha":

- July 2026: 6,296 rows

- August 2026: 16,180 rows

- September 2026: 9,550 rows

- Jun 15 – Oct 12: more than 31,000 rows

So most multi-month requests for that company would fail. Adding a typed "too large" 4xx, as v2 does, would only change which error those requests get.

## Changes

- chat/convex/finance/dashboards/expenses.ts:

- Extract aggregateVendorSpendRows from aggregateVendorSpendForPeriod. The dashboard and v2 paths call it unchanged.

- Add getVendorSpendRowsPageForApi. It paginates the same index 4,000 rows at a time, applies the same school/account filter, and returns only the fields the aggregation needs.

- Export summarizeVendorSpendRows, which produces the existing v1 { vendors, totalSpend } shape.

- Remove the now-unused getVendorSpendForPeriodForApi.

- chat/convex/publicApi/http.ts: handleFinancialsExpenseVendors loops over the pages until isDone, then summarizes.

- chat/convex/publicApi/financialsHttp.test.ts: a window larger than one page (4,005 rows) aggregates correctly, with and without a school filter. A single page query reads at most 4,000 rows and reports more remain.

## Breaking changes

None. The status codes, response body, and date-window validation are unchanged.

The Expense Analysis dashboard's queries have the same unbounded read. That is tracked separately in AERIE-2844.

## Test plan

- [x] chat: npx vitest run convex/publicApi/financialsHttp.test.ts convex/publicApi/v2/domains/finance.test.ts convex/finance (514 pass)

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

- [ ] After deploy: a 4-month alpha request returns 200

#1896 — fix(rhodes): surface searchSiteDocuments site and permission rejections instead of a generic unavailable error @marcusdAIy  approved

## Summary

When an Aerie Agent's searchSiteDocuments call is rejected because the Site doesn't exist or the user lacks access, the agent now gets that reason back as a 404 or 403. Before, it got a generic "temporarily unavailable" 502 that was captured as a Platform Error. Genuine search failures still return the generic message and are still captured. Fixes [AERIE-2856](https://linear.app/builder-team/issue/AERIE-2856).

## Why it's needed

The cause was lost at three points:

1. documentKnowledge/search.ts sanitizes authorization failures to Unauthenticated / Permission denied / Site not found, but throws them as plain Errors, which Convex redacts to "Server Error" for HTTP clients in production.

2. The worker tool's catch-all then replaced every failure with "Site document search is unavailable."

3. Since #1861 the Flue gateway classifies Rhodes tool errors by their text, but it only recognized Drive's folder-limit guard. Everything else became a captured rhodes_mcp_upstream_error.

The likeliest trigger for the captured event is an agent passing a site slug or name as siteId, which fails normalizeId with "Site not found". The agent can fix that itself once it sees the message.

## Changes

- chat/convex/documentKnowledge/search.ts: throw the three sanitized authorization outcomes as userError (ConvexError), so they reach the worker intact. Every other failure is unchanged and stays SiteDocumentSearchUnavailableError.

- chat/rhodes-worker/mcp-server/tools/documents.ts: the tool passes those three exact messages through (via siteDocumentSearchRejection, which matches an allowlist of ConvexError data). Anything else still returns "Site document search is unavailable.", so no provider detail leaks.

- chat/convex/agentRuns/http.ts: a non-retryable Rhodes tool error that says Site not found maps to gateway_not_found (404), and Permission denied maps to gateway_forbidden (403). Neither is captured, and the agent sees the text. Unauthenticated stays a captured upstream error, because on the gateway path it means the delegated identity was lost, not that the agent made a mistake.

- Tests:

- Convex: an unknown Site rejects with data: "Site not found".

- Worker: each rejection passes through verbatim, and the existing redaction test still holds.

- Gateway: not-found and permission-denied tool errors return 404 and 403 with no errorEvents; the generic unavailable error is still a captured 502.

- Each new assertion fails without its fix.

## Breaking changes

None. The public API POST .../documents/search route resolves the Site itself and doesn't use the changed authorization path.

## Test plan

- [x] chat: npx vitest run convex/documentKnowledge/search.test.ts convex/agentRuns.test.ts (135 pass)

- [x] chat/rhodes-worker: node scripts/run-tests.mjs (326 pass), npx tsc --noEmit

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

#1894 — fix(api-v2): classify admissions resource-refs readiness 503 as an expected outcome @marcusdAIy  approved

## Summary

The by-design Public API v2 readiness 503 admissions_resource_refs_not_ready is now captured as an expected outcome instead of an unexpected Platform Error. Callers get the same 503 as before. Fixes [AERIE-2867](https://linear.app/builder-team/issue/AERIE-2867).

## Why it's needed

Admissions event and camp reads fail closed with this code until the migrations/admissionsResourcePublicRefs backfill has been verified and activated. publicApiV2PlatformOutcome only treats a 503 as expected when its code is in EXPECTED_READINESS_CODES. That list already covers the sibling admissions_person_refs_not_ready gate (#1853) but not this one. As a result, every gated call opened an unexpected Platform Error: 38 so far on listAdmissionsCamps.

## Changes

- Add admissions_resource_refs_not_ready to EXPECTED_READINESS_CODES in chat/convex/publicApi/v2/platformOutcomes.ts.

- Tests:

- the parametrized readiness cases in platformOutcomes.test.ts gain the code;

- the existing fail-closed HTTP test in admissions.test.ts now also asserts that the captured event has disposition: "expected_outcome" and the matching outcomeCode. That assertion fails without the allowlist change.

## Breaking changes

None. The response status, code, and body are unchanged.

## Test plan

- [x] chat: npx vitest run convex/publicApi/v2/platformOutcomes.test.ts convex/publicApi/v2/admissions.test.ts convex/publicApi/admissionsDomainsHttp.test.ts (65 pass)

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

- [ ] After deploy: no new unexpected events for this code

#1895 — fix(rhodes-worker): reject an unreadable site Drive file instead of reporting verification unavailable @marcusdAIy  approved

## Summary

Relinking a site document to a Drive file or folder that Aerie's service account cannot see now returns the existing 422 managed_drive_file_not_authorized instead of a retryable 503 managed_drive_verification_unavailable. Real verification outages still return the 503 and are still captured. Fixes [AERIE-2866](https://linear.app/builder-team/issue/AERIE-2866).

## Why it's needed

POST /v2/portfolio/sites/{siteRef}/documents/{documentRef}/relink asks the Rhodes worker's /aerie/site-documents/verify-drive to check the caller's driveFileId and rootFolderId. The worker only returned 403 (which Aerie maps to the 422) for its own containment checks. When Google Drive itself said a caller-selected ID was NOT_FOUND (deleted, or not shared with the service account), the worker fell through to 503 drive_verification_unavailable. Aerie then told the caller to retry an identical request that could never succeed, and logged it as an unexpected Platform Error. One admin hit this 15 times over about 22 hours across several documents on the same site.

The Global documents verifier in the same file already treats NOT_FOUND and SANDBOX_VIOLATION on the selected file as "not authorized". The site verifier now does the same for both of its caller-selected IDs.

The ticket suggested adding managed_drive_verification_unavailable to EXPECTED_READINESS_CODES. I didn't, because that code also covers genuine failures (missing worker configuration, the worker being down, Drive API errors), and those should stay visible.

## Changes

- chat/rhodes-worker/src/global-document-drive-verification.ts: handleSiteDocumentDriveVerification maps DriveError NOT_FOUND / SANDBOX_VIOLATION to 403 drive_file_not_authorized, reusing the existing isDriveFileAuthorizationError.

- Tests in global-document-drive-verification.test.ts:

- an unreadable root or file (each code) returns 403. This fails without the fix.

- a Drive API_ERROR still returns 503.

## Breaking changes

None to the response contract. Callers who were getting the retryable 503 for an unreadable file now get the documented non-retryable 422 that tells them to share or move the file.

## Test plan

- [x] chat/rhodes-worker: node scripts/run-tests.mjs (325 pass), npx tsc --noEmit

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

- [ ] After deploy: relink 503s for this code drop off; any that remain point to a real outage

#1893 — Admissions API: expose by-level forecast on January milestone @vvp-trilogy  approved

## Summary

- project the published seven-level January forecast into the Admissions V2 current-forecast response

- preserve canonical public level IDs/order and return null for legacy or incomplete publications

- explain independent-rounding differences only when all comparison inputs are available

- extend the closed OpenAPI schema and HTTP integration coverage

## Validation

- pnpm --dir chat exec vitest run convex/publicApi/v2/admissions.test.ts --maxWorkers=1

- pnpm --dir chat exec vitest run lib/public-api/v2/openapi.node.test.ts lib/public-api/v2/domains/admissions.node.test.ts --maxWorkers=1

- pnpm --dir chat typecheck

- pnpm lint:test-architecture

- pnpm lint:read-bounds

- pnpm exec biome check chat/convex/publicApi/v2/domains/admissions.ts chat/lib/public-api/v2/domains/admissions.ts chat/convex/publicApi/v2/admissions.test.ts

Closes #1892

#1887 — dbt: replace legacy Pipeline mart with typed HubSpot facts @vvp-trilogy  approved

## Summary

- build the four parent-contact lead bands directly from accepted typed HubSpot contacts, programs, deals, sessions, and associations

- build paid Community Commitment rows directly at deal grain, including historical sessions outside the current admissions-deal eligibility window

- resolve Finalsite identity through canonical program identity

- remove stg_educrm_pipeline and the pipeline_dtl dbt source dependency

## Validation

- dbt parse --no-partial-parse

- manifest contains no educrm.pipeline_dtl source node

- replacement dependencies are typed HubSpot staging plus canonical Aerie identity only

- accepted alpha publications were ready (published 2026-10-13 20:35–20:41 UTC) for contacts, deals, associations, definitions, programs, and sessions

- governed association-definition assertion covers deal→child (3), Parent-Child (81), session→program (265), and deal→session (309)

- PR-prefixed lead grain is unique: 43,204 Lead; 16,987 MQL; 2 Summer no-payment; 2 Summer paid

- legacy/direct key overlap:

- Lead: 18,960 matched, 138 legacy-only, 24,244 typed-only

- MQL: 7,565 matched, 221 legacy-only, 9,422 typed-only

- Summer no-payment: 1 matched, 1 typed-only

- Summer paid: 1 matched, 1 typed-only

- lead expansion is intentional current accepted-publication coverage: typed-only keys are dominated by current Alpha Anywhere (14,664 Lead / 8,175 MQL) and GT Anywhere (4,159 Lead / 287 MQL) program interests; 18,357 Lead and 9,309 MQL rows intentionally retain null Finalsite tenants for pre-launch markets

- paid Community Commitment: all 351 legacy deal keys matched, plus 14 current typed-only deals; replacement is unique at 365 deals (220 intentionally null Finalsite tenants)

- current-head dbt build/test and repository CI (linked checks below)

Closes #1878

#1890 — fix(dbt): align typed program reportability @vvp-trilogy  approved

## Summary

- exclude HubSpot programs hidden from dashboards at the shared typed staging boundary

- exclude _test_ program records to match the reporting program dimension

- cover both reportability filters in the staging unit test

## Production evidence

- Alpha Orlando (26463204892) and Alpha Folsom (29735717529) both have show_in_dashboard = false

- applying the reporting filters reduces the typed active/ready set from 118 to 95 programs, matching sales_educrm_wh_mart_all_program

## Validation

- dbt parse --no-partial-parse

- focused dbt unit test: PASS

- live Redshift predicate check: Orlando and Folsom excluded

#20 — feat(dss): publish the quality-bar catalog and changed-since sweeps @kevalshahtrilogy  no labels

Linear: AERIE-2854, AERIE-2855 (the upstream changes this publishes)

## What changed

Publishes two read improvements that went to production in Aerie on 2026-10-14.

- New operation: GET /api/v1/insights/quality-bars/catalog, the scoring catalog quality-bar scores are computed against. The surface is now 45 operations (43 GET, 2 POST).

- Refreshed contract from the system of record. Site list rows and site detail now carry lastChangedAt, and the site list accepts changedSince.

- Enablement: two new sections, "Re-read only what changed" and "Check how a quality-bar score was computed", and two rows in the business-question table. Both state the limit: lastChangedAt sees audited changes only, so scheduled copies and bulk imports do not count.

- Dictionary: the operation count only (42 → 43 GET). No other change to the published definitions.

- Name rewrite: the refreshed contract carried a fixed actor label, "External, no Aerie account". It is now renamed in responses and the contract, matched as a whole value only, so a person's text containing the same words is untouched.

## Business Value

The registry and JC's agents sweep the portfolio under a shared 10-per-minute budget, about an hour and a half per pass. Re-reading only what changed makes a frequent pass affordable. The catalog closes an open registry finding: quality-bar scores can now be checked rather than taken on trust.

## Manual Effort Estimate

About 2 hours by hand. Proposed figure; Keval to confirm or adjust.

## Testing

- pnpm check passes locally except two infrastructure tests that time out at the default 5 s on this machine (CDK synth); they pass with a longer timeout.

- Contract test confirms no upstream platform name in the published contract, including the new label.

- Every route cited in the enablement is published and uses only declared parameters.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#1860 — feat(public-api): lastChangedAt on Portfolio sites and a changedSince list filter (AERIE-2855) @kevalshahtrilogy  approved

Linear: AERIE-2855

## Testing contract

### What this PR delivers

Two additions to the Portfolio site reads, so a caller can tell which sites changed without re-reading the whole portfolio.

1. lastChangedAt on each row of GET /v2/portfolio/sites and on GET /v2/portfolio/sites/{siteRef}: the time of the latest audit entry recorded against the site. That covers the site record and its audited child records (notes, documents, work, backup links). Null when no audited change is on record.

2. changedSince query filter on GET /v2/portfolio/sites: return only sites whose lastChangedAt is at or after the given instant.

Why: a full read of the 88 active sites is about 2,700 calls today because nothing says what moved, and no served value says when a site last changed. With these, a repeat sweep lists the sites changed since its last run and re-reads only those.

### Who uses it and where

API-key callers with operations.portfolio.read (the existing capability for both reads). No UI change. No new operation.

### Conditions needed

- Sites with and without rows in auditLog.

- For the filter: at least one site with an audit row newer than the chosen instant and one without.

### Expected behavior and examples

- A site with no audit rows returns lastChangedAt: null.

- A site with audit rows returns the newest row's time, whichever record it was written against. Example: one audit row against the site and a later one against a note on it → the later time.

- ?changedSince=2026-10-01T00:00:00Z returns only sites changed at or after that instant; a site with null is never returned. The comparison is inclusive.

- The filter is applied after the page is read, like the existing status filter, so a page can be short or empty while hasMore is true. The contract tells callers to follow the cursor to the end. Example: limit=1 with the match on the second site → first page empty with hasMore: true, second page has the match.

- The filter is bound into the cursor. Using a cursor with a different changedSince (or without it) returns 400 invalid_cursor. Cursors issued before this change carry no filter and keep working unfiltered.

- A value that is not an ISO date-time with an offset (a bare date, free text, a time with no zone) returns 400.

- lastChangedAt is one indexed read per site (auditLog.by_site, newest first), so a page of 100 is at most 100 extra reads.

### Limits and unanswered questions

- This is "last audited change", not a full modification time. Writers that skip the audit log do not move it: the scheduled REBL geography copy, the school-calendar copy, the HubSpot program-code copy, and raw imports. Both the field and the filter say so in the published contract, and the filter tells callers to run an unfiltered sweep periodically. Closing this properly needs a stamped sites.updatedAt with a backfill, or audit entries on those writers. Not attempted here.

- The value is per site, not per card. A per-card updatedAt was asked for and is not delivered: the audit row does not say which card changed.

- lastChangedAt is a new required field on two response schemas. Additive for JSON consumers, but a strict client validating against a pinned schema will need the new contract.

- Not run locally: the full repository test suite. CI runs it.

## Business Value

Edu Ops' agents and the DSS registry sweep the portfolio on a schedule, and the sweep is bounded by the shared rate limit: about an hour and a half for a full pass. Reading only what changed turns most passes into a handful of calls, which is what makes a frequent data-quality loop affordable.

## Manual Effort Estimate

About 6 hours by hand (trace audit coverage, design the cursor binding, implement, test). Proposed figure; Keval to confirm or adjust.

## Testing

- New HTTP test covers null and non-null values on list and detail, the inclusive filter, a future instant, sparse paging, cursor binding and three malformed inputs.

- portfolioDomain.test.ts passes (30 tests); the existing exact-body list test is updated for the new field.

- Pre-commit type check and lint pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#1859 — feat(public-api): serve the quality-bar scoring catalog (AERIE-2854) @kevalshahtrilogy  approved

Linear: AERIE-2854

## Testing contract

### What this PR delivers

A new read, GET /v2/insights/quality-bars/catalog, that returns the P2 scoring catalog quality-bar insights are scored against.

Today GET /v2/insights/quality-bars returns each bar's score and names a versioned catalog in meta.source.catalog, but the catalog itself is not served. A reader gets a number with no way to check or explain it. This was raised by the DSS registry's evaluation of the Rhodes data source.

The response carries:

- the thirteen quality bars, each with its campus-score multiplier, its possible points and its groups with their maximum points;

- weightedPossiblePoints, the campus-score denominator;

- scoring, the fixed rules: share of points earned at green, amber, red and unassessed, and the bar-status thresholds;

- groupSource, where the effective group rows come from (schema template, active Convex revision, or code default);

- meta.source.catalog, the same catalog revision name that quality-bar insights report.

### Who uses it and where

API-key callers with operations.operating.read, the same capability as the quality-bar insights read. Standard rate tier. No UI change.

### Conditions needed

- An API key with operations.operating.read.

- No site is needed; the catalog is not site-specific.

- Two meaningful states: no active catalog rows (code default) and an active Convex catalog revision.

### Expected behavior and examples

- With no active rows: meta.source.catalog is {source: "default", methodologyKey: "p2-rag-campus-scoring-v1", version: 1} and thirteen bars are returned.

- Each bar's possiblePoints equals the sum of its groups' maxScore.

- weightedPossiblePoints equals the sum of possiblePoints × multiplier over all bars. Example: thirteen bars of 100 points with multiplier 1 → 1300.

- With an active Convex revision at version 7: meta.source.catalog reports source: "convex", version: 7.

- It applies the same bounds and single-revision checks as the insights read and fails closed with 503 on a violated invariant.

- Missing key → 401.

The scoring rules are served from the engine's own constants. p2Scoring.ts now names the point fractions and thresholds it already used (RAG_POINT_FRACTIONS, RAG_SCORE_THRESHOLDS); behaviour is unchanged.

### Limits and unanswered questions

- The catalog is the template. A site is scored on its own active groups and their own maximum points, which can differ from the template. The response says so.

- meta.source.catalog.source keeps its existing two values; when the effective groups come from the schema template, that shows only in groupSource. I left the existing field's meaning alone.

- Not run: the full repository test suite locally. CI runs it.

## Business Value

Edu Ops and their agents are asked to act on quality-bar scores for operating schools. A score that cannot be recomputed has to be taken on trust. This lets any reader show its working, and closes one of the open findings from the DSS registry's grading of Rhodes.

## Manual Effort Estimate

About 4 hours by hand. Proposed figure; Keval to confirm or adjust.

## Testing

- New HTTP test covers both catalog states, the recomputation identities and the 401.

- insights.test.ts, routeManifest.test.ts and the agent-context coverage tests pass (57 tests).

- Pre-commit type check and lint pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#1886 — dbt: retire legacy enrollment parity harness @vvp-trilogy  approved

## Summary

- remove the obsolete SIS-vs-EduCRM enrollment parity analysis

- remove its now-unused legacy enrollment_dtl dbt source declaration

- preserve runtime readers and unrelated historical analyses for their separately tracked migrations

## Validation

- git diff --check

- REDSHIFT_HOST=localhost REDSHIFT_USER=dummy REDSHIFT_PASSWORD=dummy REDSHIFT_DATABASE=finance_dw DBT_PROFILES_DIR=/home/ubuntu/aerie/control/dbt poetry run dbt parse --no-partial-parse

- inspected target/manifest.json: no educrm.enrollment_dtl source node

- repository scan: no retired parity analysis name or source("educrm", "enrollment_dtl") invocation

Closes #1877

#1885 — dbt: replace EduCRM all_program with typed HubSpot programs @vvp-trilogy  approved

## Summary

- publish typed HubSpot program code/name aliases through the accepted Alpha staging boundary

- exclude archived and publication-unready program records at that shared boundary

- move canonical program identity, SIS campus resolution, and the projection compatibility bridge off stg_educrm_all_program

- remove the obsolete staging model and all_program dbt source declaration

## Warehouse comparison

Read-only Redshift comparison on 2026-10-14:

- legacy all_program: 95 distinct program IDs

- typed Alpha programs: 123 IDs total; 118 active and publication-ready

- every one of the 95 legacy IDs exists in typed programs with identical program code and display name

- 23 active/ready typed-only IDs expand source coverage; 5 additional typed-only IDs are archived test rows and are excluded

- built pr1876_int_program_identity versus current int_program_identity: 118 rows before, 118 rows after, 0 differing rows across every published column

## Validation

- dbt parse --no-partial-parse

- exact unit test: stg_education_hubspot_program_keeps_only_active_ready_alpha_rows (pass)

- dbt build --select +int_program_identity --indirect-selection cautious --vars {pr_number: 1876} (42/42 pass)

- related SIS/projection build (23 pass, 1 existing WARN for four unresolved SIS campuses)

- full PR model/seed build with tests excluded (148/148 pass)

- full dbt test (740 pass, 11 warnings, 3 failures all on newly built Alpha Orlando 2026 forecast state: assert_forecast_session_1_reconciles, assert_forecast_unavailable_without_rate, and assert_forecast_v4_publication_contract; the same three assertions pass against the current materialized production models)

- static scan confirms no dbt SQL/YAML reference remains to stg_educrm_all_program or source(educrm, all_program)

Closes #1876

#3 — Clarify uncaptured security feedback in reading rules @caina-barbosa  no labels

Qualifies the source-null security-review rules with the September field-coverage exception directly where agents read them. This keeps the supplied meanings while preventing emitted not-captured nulls from being counted as unreviewed security rows. Validation: lint passes; documentation-only clarification.

#2 — Serve the Marauders Map dictionary from September snapshots @caina-barbosa  no labels

Implements all nine source-shaped Dictionary routes and five Enablement workflows against existing Surtr Redshift snapshots. Selects whole successful publications by school, local date and incident capture bounds; preserves source fields and excludes media/demo alerts. Missing security feedback is null with explicit not-captured coverage, so independent security-review counts remain unavailable. Adds the isolated reader secret and scoped warehouse network access.

Validation: 19 tests pass (configured-template setup test skipped), independent snapshot review passes, CI including Docker and redacted secret scan is green. All nine routes and five workflows pass through the deployed HTTPS service and match local real-warehouse results. Served inventory: 1 school, 2 floors, 11 locations, 16 cameras. Reports: 23 space, 35 presence, 12 attention, 27 risk rows. Incidents: 50 served after four demo exclusions; September has 17 rows, three verifier tier-1 grades, and 17 source-null guide reviews. Security counts are unavailable; camera heartbeat nulls leave outage duration unknown. Missing dates return explicit not-captured coverage.

Final service: https://marauders-map-dss.klair.ai/api/skill . Final release and CI succeeded for main 5613b2ed59bbe55bc296046fb61f909739106b98. Internal validation keys are revoked; no recipient keys issued. No Surtr/source/schedule changes or pipeline invocations.

#1 — Deploy isolated Marauders Map DSS foundation @caina-barbosa  no labels

Creates the standalone snapshot service from DSS-Template with isolated key, feedback, ECS and deployment resources. Retains manual protected OIDC releases and adapts the supplied public documents to September snapshot semantics. Data routes follow in the next change. Validation: template tests, typecheck and build pass.

#1883 — Events report: preserve is_brought_by_event semantics @vvp-trilogy  approved

Closes #1873

## Summary

- replace is_new_contact with the established is_brought_by_event contract

- retain the event-capped effective registration timestamp through the event mart

- add focused divergence tests and participant-level legacy reconciliation

## Validation

- dbt parse --no-partial-parse

- exact participant unit test

- targeted affected-model dbt tests (23/23 pass)

- real PR-prefixed mart build and warehouse reconciliation

## Reconciliation note

The direct mart reproduces the reviewed classification rules. Remaining legacy deltas are concentrated in known source-resolution differences: the legacy mart registration timestamp often comes from its historical form/registration enrichment, while the direct HubSpot mart intentionally retains #1871 participation resolution. No new stages or aggregates are introduced.

#1872 — Events report: add direct HubSpot event mart @vvp-trilogy  approved

## Summary

- normalize the HubSpot dbt boundary to the supported alpha portal and remove portal coordinates downstream

- add deterministic event participation, attendee-only admissions outcomes, supported facts, and the event × stage detail mart

- add source-readiness, association, temporal, grain, factless-grid, and scenario coverage

- add a reviewed program-interest mapping snapshot and repeatable legacy-mart reconciliation

## Reconciliation gate

Across 1,663 overlapping reportable events:

| stage | direct HubSpot | legacy | difference |

| --- | ---: | ---: | ---: |

| registered | 52,784 | 52,683 | +0.19% |

| attended | 19,943 | 19,918 | +0.13% |

| no show | 29,609 | 29,489 | +0.41% |

Comparable attendee-seeded, post-event applications are 2,082 direct versus 2,016 legacy (+3.3%). Larger shadow/enrollment reductions are expected and classified: registered-not-attended attribution and pre-event milestones are deliberately removed, and the new contract selects one deterministic earliest deal. See dbt/docs/marketing-event-reconciliation.md.

One source anomaly remains explicitly called out: event 424418156150 has 91 direct HubSpot participants versus one legacy registration; no other event exceeds the large-delta threshold.

## Validation

- poetry run dbt parse --no-partial-parse

- poetry run dbt ls --select +mart_admissions_marketing_event_dtl

- read-only warehouse reconciliation against staging_education.sales_educrm_wh_mart_marketing_event_dtl

- exact dbt builds and tests delegated to PR CI because local Redshift write credentials are unavailable

Closes #1871

#1875 — fix(rhodes): fail a Drive upload terminally when the uploaded file does not match the approval @marcusdAIy  approved

## Summary

When a Drive upload succeeds but the stored file doesn't match the approved filename, MIME type or byte length, the Rhodes upload approval now fails with a clear "ask Rhodes to propose the upload again" message. Previously it stayed stuck, and every "Approve again" failed the same way.

Fixes [AERIE-2832](https://linear.app/builder-team/issue/AERIE-2832).

## Why it's needed

verifyAndCompleteAerieDriveUpload threw on a metadata mismatch without changing the pending mutation. The row stayed uploading with uploadedFileCandidateId set, so getUploadSessionDisposition returned verify_candidate on every retry, which re-checked the same immutable Drive file against the same approved args. Meanwhile the card told the user to "Click Approve again… the file will not be uploaded again", so they kept retrying a check that could never pass. This was captured twice for one Moderator-Approvals user, on two permit uploads.

## Changes

- rhodes-worker/src/upload-recovery.ts: new uploadedDriveFileApprovalMismatch helper holding the existing name, MIME type and byte-length comparisons, with the same messages.

- rhodes-worker/src/index.ts: on a mismatch, verifyAndCompleteAerieDriveUpload now calls markPendingMutationFailed and returns the failed result. The error names the Drive file ID so the stray file can be found. This covers the upload-complete route, and the verify_candidate and intent-recovery paths of upload-session, so rows already stuck in uploading reach the failed state on their next approval.

- components/rhodes-mutation-card.tsx: a failed result from upload-session or upload-complete raises RhodesUploadProposalMismatchError. That puts the card in its existing needs-new-proposal state, skips Platform Error capture, and no longer adds "Click Approve again". The staged attachment is kept for the new proposal.

## Breaking changes

None. Once a mismatch happens, the pending mutation is failed instead of uploading, so the user re-proposes rather than retrying.

## Test plan

- [x] rhodes-worker: new helper test, full worker suite (323 tests) and tsc --noEmit pass.

- [x] rhodes-mutation-card.node.test.ts: new test covering a failed upload-complete result and a retry where upload-session returns failed. It fails without the fix, because the error is the "Drive upload succeeded… Click Approve again" message.

- [x] tool-call.test.tsx upload and registration tests pass (24).

- [x] Pre-commit biome and typecheck pass.

#1874 — feat(education): name regulatory approval cards @benji-bizzell  approved

## Summary

- Add editable Approval name titles to Education Regulatory Approval cards.

- Require a nonblank name for new entries through the UI and API.

- Preserve generated titles for existing unnamed approvals so they can be named after deployment.

## Why

Multiple approvals at one site currently appear as Approval 1, Approval 2, etc. Staff need to distinguish Boston Public Schools approval from Cognia accreditation while each retains its own status, dates, capacity, and stable identity.

## Business Value

Staff and API clients can identify and rename approvals without recreating records or losing approval history. Existing records remain readable and editable throughout rollout.

## Breaking changes

API v2 approval creation now requires approvalName. Existing approval reads and updates remain compatible; clients creating entries must send a nonblank name (maximum 200 characters).

## Test plan

- [x] 335 focused contract, card, provider, Convex, route, schema, and OpenAPI tests pass.

- [x] Chat/Convex and contracts typechecks, formatting, and architecture checks pass.

- [x] Authenticated localhost clickthrough verifies required-name validation, draft title, status editing, confirmation diff, and cancellation.

- [x] Automated API tests verify legacy renaming preserves IDs, dates, status, and capacity.

- Live clickthrough stopped before final confirmation; no records or upstream systems were changed.

## Rollout

Deploy the backend and frontend together, then refresh existing site-detail tabs before naming records. Older clients reject the newly stored approvalName field. After names are recorded, backend rollback must retain a reader compatible with approvalName; rolling back to the pre-feature strict reader requires a deliberate data-preservation plan.

## Screenshot

![Named approval confirmation](https://raw.githubusercontent.com/AI-Builder-Team/Aerie/bran/education-approval-names/docs/images/education-approval-name-confirmation.jpg)

#1869 — fix(work-management): report user-directory readiness instead of integrity failure on reads @marcusdAIy  approved

## Summary

Work Management read APIs now return 503 user_directory_identity_not_ready when a row's user reference can't be resolved because the platform-user identity directory isn't verified yet. Previously they returned 503 work_management_reference_integrity_failed, which says operator repair is required.

Fixes [AERIE-2820](https://linear.app/builder-team/issue/AERIE-2820).

## Why it's needed

publicUserReference returns null while the user directory isn't verified. The list and get handlers treated every null projection as stored-data corruption. One actor hit that 9 times in about 24 minutes across three sites, and each hit was captured as an unexpected Platform Error. sitePeople and the Directory API already report this same condition as user_directory_identity_not_ready, and that code is on the expected-readiness allowlist in platformOutcomes.ts.

## Changes

- workManagementData.ts:

- publicUserReference flags the shared reference cache when readiness blocks a user reference.

- The read handlers that resolve user references return user_directory_not_ready only when that flag is set. Those handlers are the work unit list and get, the task list and get, listMyTasks, and task comments.

- Every other null projection still returns integrity_error, including duplicate or mismatched user identities, which sitePeople also treats as inconsistent.

- getWorkUnit and getTask now create their own reference cache so they can see the flag.

- domains/workManagement.ts: maps user_directory_not_ready to the same 503 user_directory_identity_not_ready response the Directory API uses.

- workManagement.test.ts: after the existing corruption assertions, the test gives the work unit an assignee and confirms a 200. It then removes the directory verification row and expects user_directory_identity_not_ready from the work-unit detail, work-unit list and task list routes, with each captured event recorded as an expected_outcome.

## Breaking changes

None. Clients see a different 503 error code during directory readiness only. Rows with corrupt data still return work_management_reference_integrity_failed.

## Test plan

- [x] npx vitest run convex/publicApi/v2/: 396 tests pass, including the existing integrity-failure coverage.

- [x] The new assertions fail without the fix: the response code is work_management_reference_integrity_failed.

- [x] Pre-commit hooks pass: convex-paths, biome and typecheck.

#1850 — Admissions Forecast: display January forecast by level @vvp-trilogy  approved

## Summary

- add the governed seven-level Forecast V2 contract and rollout-compatible storage/read support

- read and fail-closed validate the by-level mart in the existing Redshift snapshot before publication

- render the January total card as a responsive overall/by-level split with totals and independent-rounding guidance

## Validation

- pnpm --dir sync exec vitest run src/redshift/admissions-forecast.test.ts src/analytics/admissions-forecast-refresh.test.ts --maxWorkers=1

- pnpm --dir chat exec vitest run convex/admissions/forecastV2.test.ts components/dashboards/admissions/forecast/v2/__tests__/forecast-v2-report.test.tsx --maxWorkers=1

- pnpm typecheck

- pnpm lint:test-architecture

- Biome on all changed files

Closes #1848

#1863 — fix(school-ops): validate operating sites one at a time so one bad record can't 502 the dashboard @marcusdAIy  approved

## Summary

The School Ops dashboard now loads even when one site from Aerie fails the operating-site schema. Invalid sites are skipped and logged with their ID, slug, and the failing field paths. The load still fails with 502 only when no site validates.

Fixes [AERIE-2823](https://linear.app/builder-team/issue/AERIE-2823).

## Why it's needed

fetchRhodesDashboardSites ran rhodesOperatingResponseSchema.safeParse on the whole sites array. One non-conforming site rejected the batch with "Aerie returned an unexpected site shape" (502), so nobody could load the dashboard: 10 events across 5 actors. The Zod issues were logged without saying which site they belonged to, so the bad record couldn't be identified. This is the same all-or-nothing behaviour behind AERIE-1053; that fix (#571) only relaxed the DRI-ref sub-schema.

## Changes

- chat/lib/rhodes-dashboard-server.ts:

- validate the envelope (sites must be an array), then parse each site with rhodesOperatingSiteSchema;

- skip invalid sites and console.error a summary: rejected and accepted counts, plus _id, slug, and each issue's path, code, and message for up to 20 sites;

- return the existing 502 only when the array is non-empty and nothing validates.

- rhodes-dashboard-server.node.test.ts: a payload with one valid site and one with a non-numeric tuition returns the valid site and logs site_2 / tuition. This test fails without the change. The existing all-invalid test still expects 502.

## Breaking changes

None. Valid payloads are unchanged, and rhodesOperatingResponseSchema is still exported for its other users.

## Test plan

- [x] chat: npx vitest run lib/__tests__/rhodes-dashboard-server.node.test.ts lib/__tests__/operating-sites.node.test.ts lib/__tests__/rhodes-operating-contract.node.test.ts app/api/operating-sites app/api/sync/accountability (134 pass)

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

- [ ] After deploy: no new load_operating_sites 502s; check the logs for "Skipped Aerie sites that failed validation" to find the bad record

#1867 — fix(rhodes): pause standalone registerDocument proposals that have no file or link @marcusdAIy  approved

## Summary

A registerDocument proposal with no Drive file, Drive URL, or external link, and no paired upload card, no longer shows an Approve button that can only fail. It now gets the reject-only "Document registration paused" card, which explains why it can't be filed.

Fixes [AERIE-2800](https://linear.app/builder-team/issue/AERIE-2800).

## Why it's needed

The event was a rhodes_approve failure on the generic Rhodes Mutation Card: "Must provide either driveFileId (for Drive-linked docs) or externalUrl (for external refs)". A registerDocument without a file reference only works when RhodesDocumentFilingCard pairs it with an upload and passes the uploaded driveFileId as overrideArgs. When there's no pairing, the proposal fell through to the generic card. Approving that card sends the bare args to registerDocument(), which always throws. ToolCallGroup already paused unpaired proposals that carry a sourceTempId, but not ones with no sourceTempId. Tool calls rendered as single ToolCalls, such as those inside thinking blocks, skipped that check entirely.

I first tried rejecting these proposals when they're created, but rhodesMcpMutationParity shows that name-matched upload pairs legitimately create link-less registrations. So the fix is in the UI.

## Changes

- New chat/components/unfileable-document-registration-card.tsx:

- isUnfileableRegisterDocumentProposal: a registerDocument with no non-empty driveFileId, driveUrl, or externalUrl;

- the paused card, moved from tool-call-group.tsx; its message depends on whether a sourceTempId was given.

- tool-call-group.tsx: uses the shared predicate and card after pairing has been attempted, so composed upload + register filing is unchanged.

- tool-call.tsx: renders the paused card instead of RhodesMutationCard for these proposals, unless it's a history row whose pending mutation is no longer pending.

- tool-call.test.tsx:

- a new test for a single ToolCall with a link-less registration; it fails without the change;

- three existing tests that asserted an unpaired link-less registration stays a generic approval card now assert it isn't composed and is paused.

## Breaking changes

None for working flows: paired uploads, Drive-linked and external-link registrations, and uploadAndRegisterDocument render as before.

## Test plan

- [x] chat: npx vitest run components (4212 pass; the one failure is the known portfolio-rhodes-workbench "separate topic" timeout, which also fails locally on main)

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

- [ ] After deploy: no new rhodes_approve "Must provide either driveFileId" captures

#1866 — fix(public-api): return Rhodes note userError rejections as 400s instead of opaque 500s @marcusdAIy  approved

## Summary

POST /v1/operations/rhodes/notes (and the list route) now return business-rule rejections from the note-write pipeline as 400 invalid_request with the reason, such as "Closed Tasks cannot be changed". Before, they came back as an opaque 500 note_create_failed and were captured as an unexpected Platform Error.

Fixes [AERIE-2825](https://linear.app/builder-team/issue/AERIE-2825).

## Why it's needed

rhodesNoteApiErrorFromUnknown only recognized payloads that exactly match a RHODES_NOTE_ERRORS status/code/message entry. Rejections raised with userError, which is a ConvexError whose data is a plain string, fell through to the 500 branch. requireTaskNoteWritable's closed-Task guard is the concrete case: one API caller hit it 4 times and got only "Rhodes note creation failed" each time.

## Changes

- chat/convex/publicApi/http.ts: after the typed RHODES_NOTE_ERRORS match, a ConvexError with a plain, non-JSON string payload returns 400 invalid_request with that message. This is the same convexUserMessage mapping the Forge translator in this file already uses. Any future userError in commitNote / resolveNoteAnchor is covered without an allowlist. Unrecognized structured payloads still return a redacted 500.

- chat/convex/publicApi/operationsHttp.test.ts:

- a translator assertion for new ConvexError("Closed Tasks cannot be changed");

- a route test that posts a note anchored to a completed Task and gets a 400 with that message and no errorEvents.

Both fail without the change.

## Not changed

"Note author could not be resolved" stays a captured 500. It means the API key resolved to a user that the note writer then couldn't find, which the caller can't fix and is worth seeing in Platform Errors.

## Breaking changes

Clients that relied on a 500 for these rejections now get a 400 with a readable message.

## Test plan

- [x] chat: npx vitest run convex/publicApi (552 pass)

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

- [ ] After deploy: no new note_create_failed captures for closed-Task notes

#2160 — fix(aws-spend): map Core Education RDS accounts @caina-barbosa  approved

## Summary

This PR records the production account-mapping correction for two newly cost-bearing Core Education accounts:

- 090885120154 → Prod-CoreEducation-AlphaRE

- 419799697623 → Dev-2HRLearning-beyondaipoola

Both are mapped to class = Core Education and bu = Core Education, projected from 2026-Q4 through the existing 2030-Q4 horizon.

Production effect: controlled migration already completed and verified.

## Why

The saas-budgeting-pipeline run 30108350-3e01-49bc-886b-2de6b91ae878 failed closed on the noncentral_charges ingest:

ValueError: account mapping is incomplete for 2026-Q4: ['090885120154', '419799697623']

All other ingests (docker, k8s, database_units, mapping, server_costs) published successfully. The account-mapping guard correctly retained the prior non-central-charge publication rather than emitting incomplete finance data.

## Business Value

- Restores the Q4 non-central RDS charge publication with complete account ownership.

- Attributes both new accounts to the confirmed Core Education class and BU.

- Preserves a reproducible, idempotent record of the production correction through 2030-Q4.

## How does it work

1. Both accounts report RDS costs under master payer 572481847476 (VDI).

2. AWS Cost Explorer linked-account metadata returned Prod-CoreEducation-AlphaRE and Dev-2HRLearning-beyondaipoola.

3. Prod-CoreEducation-AlphaRE carries the CoreEducation ownership prefix. The second account matches the existing Dev-2HRLearning-beyondai and Prod-2HRLearning-beyondai peers, both governed as Core Education.

4. The migration performs a scoped delete and insert for only these two accounts and the 2026-Q4–2030-Q4 horizon, making it safely idempotent.

5. The all-payer Q4 RDS anti-join and bounded production rerun verify the resulting publication.

## Scope

### Included in this phase

- Two governed AWS account mappings with confirmed names, class, and BU.

- Seventeen quarter rows per account from 2026-Q4 through 2030-Q4.

- Production application and bounded noncentral_charges verification evidence.

- Exact final diff path:

pipelines/ddl/2026-10-13_q4_account_mapping_coreeducation_accounts.sql

### Deliberately excluded for later phases

- Pipeline code or infrastructure changes—the existing fail-closed behavior is correct.

- Changes to the Klair-owned skip list or charge policy.

- Re-execution of the other five successful ingests.

## Test plan

### Automated validation

- Production red check—Q4 RDS anti-join returned exactly 090885120154 and 419799697623 before the change.

- DDL parse validation—5 statements with an explicit transaction and verification query.

- Focused pipeline tests—33/33 passed (uv run pytest -q tests/test_noncentral_charges.py).

- git diff --check—passed.

- Exact-head diff scope—only the single authorized DDL path listed above.

### Time for Implementation

Approximately half a day for an engineer to investigate account ownership, prepare and review the migration, apply it, run the bounded production verification, and raise the PR.

### Manual QC

Applied to finance_dw on 2026-10-13 and verified 17 rows per account, 34 total, covering 2026-Q4 through 2030-Q4. The post-commit all-payer Q4 RDS anti-join returned zero gaps.

Bounded Step Functions execution manual-noncentral-coreeducation-20261013 succeeded with pipeline run e6af40e6-8a01-43a7-a919-303c536e218a:

- Source through 2026-10-12

- 206 candidate/fetched/inserted/verified accounts

- Replaced 204 prior rows with 206 current rows

- 150 billable accounts; charge total $150,000

- Mapping gap count: 0

- Both corrected accounts published with the confirmed Core Education classification and $1,000 charge policy result

#1854 — chore(task-board): remove completed rollout scaffolding (AERIE-2852) @caina-barbosa  approved

## Summary

This cleanup belongs to the [Human Task Assignment from Aerie project](https://linear.app/builder-team/project/human-task-assignment-from-aerie-b3582376ae6e). [AERIE-2852](https://linear.app/builder-team/issue/AERIE-2852/clean-up-completed-task-board-rollout-documents-and-obsolete-gate) removes completed rollout instructions, UI delivery scaffolding, mock images and unused launch code after the Task Board release.

Production effect: cleanup or removal. Users will see no change to Tasks, permissions, notifications or email delivery.

## Why

The old launch document says the UI is hidden and the approval API is not yet available. Both statements are outdated. An unused feature-switch helper and obsolete test setup also imply a rollout gate that no live caller uses.

## Business Value

- Removes misleading deployment and rollback instructions.

- Keeps approved product and interaction decisions together in FEATURE.md.

- Preserves the approved Task behavior and existing regression coverage.

## How does it work

1. Removes the completed launch and UI delivery documents, all six UI mock images and their README. No replacement maintenance runbook is added.

2. Consolidates approved UI interactions into FEATURE.md and removes completed rollout history, census, delivery decomposition and engineering checklist scaffolding.

3. Removes broken documentation-index links while retaining the FEATURE.md entry. Runtime repair functions and approved Task behavior remain unchanged.

4. Deletes the unused taskBoardEnabled helper. Repository searches found no imports or callers.

5. Removes only obsolete environment setup from tests. Their behavior assertions remain.

## Scope

### Included in this phase

Exact final diff paths:

chat/convex/agentRuns.test.ts

chat/convex/publicApi/v2/siteNotes.test.ts

chat/convex/rhodesMcpMutationParity.test.ts

chat/convex/taskBoard/approval.test.ts

chat/convex/taskBoard/assignments.test.ts

chat/convex/taskBoard/lifecycle.test.ts

chat/convex/taskBoard/mutations.test.ts

chat/convex/taskBoard/notifications.test.ts

chat/convex/taskBoard/queries.test.ts

chat/lib/task-board/gate.ts

chat/rhodes-worker/mcp-server/tools/portfolioParity.test.ts

docs/README.md

docs/task-board/FEATURE.md

docs/task-board/LAUNCH.md

docs/task-board/UI.md

docs/task-board/ui-mocks/README.md

docs/task-board/ui-mocks/create-task.png

docs/task-board/ui-mocks/my-tasks-filters-open.png

docs/task-board/ui-mocks/my-tasks-grouped.png

docs/task-board/ui-mocks/my-tasks-simple.png

docs/task-board/ui-mocks/task-detail-awaiting-approval.png

docs/task-board/ui-mocks/team-tasks-by-site.png

### Deliberately excluded for later phases

- Task behavior, API contracts, permission rules, migrations and production configuration are unchanged.

## Test plan

### Automated validation

- Task and API regression tests: 106 passed (pnpm --dir chat exec vitest run convex/taskBoard/notifications.test.ts convex/taskBoard/queries.test.ts convex/taskBoard/assignments.test.ts convex/taskBoard/mutations.test.ts convex/taskBoard/approval.test.ts convex/taskBoard/lifecycle.test.ts convex/publicApi/v2/siteNotes.test.ts --maxWorkers=1).

- Task agent regression tests: 12 passed, 209 unrelated tests skipped by name filter (pnpm --dir chat exec vitest run convex/rhodesMcpMutationParity.test.ts convex/agentRuns.test.ts --testNamePattern='Task|task' --maxWorkers=1).

- Exact MCP worker tests: 5 passed (pnpm exec tsx --test chat/rhodes-worker/mcp-server/tools/portfolioParity.test.ts).

- Test runtime routing: passed (pnpm lint:test-architecture).

- Documentation links: passed (pnpm lint:knowledge).

- Chat typecheck: passed (./chat/node_modules/.bin/tsc --noEmit -p chat/tsconfig.json, mandatory commit hook).

- Convex module paths: passed (pnpm lint:convex-paths, mandatory commit hook).

- Biome: passed for all 10 changed test files.

- git diff --check: passed.

- Independent review inspected every changed path and reran 32 approval, assignment and notification tests successfully.

- Final hosted CI: all 8 CI checks passed on b639558225cf7a83bb6dfc2061bcb7c37080500a ([CI run](https://github.com/AI-Builder-Team/Aerie/actions/runs/38437344722)).

- [Mercy approved the revised cleanup](https://github.com/AI-Builder-Team/Aerie/pull/1854#pullrequestreview-5507150898) with no findings. GitHub retained that approval after a main-only branch update left the cleanup diff unchanged.

### Time for Implementation

About one engineering day to audit callers, preserve product decisions, clean up the documents and tests, and validate the change.

## Review repairs and contract clarifications

The user approved removing the completed UI artifacts and the replacement maintenance runbook. Approved interactions remain in FEATURE.md. Repair functions, Task behavior, API contracts, roles and digest delivery settings are unchanged.

Merged as 6dd26be872e14b0f6cb18a5e93064b9fe447fa28. Final documentation links and whitespace checks passed. No deployment, production maintenance or email activation was performed.

#1861 — fix(agent-gateway): return Drive's folder search-scope limit to the agent instead of a transient Rhodes MCP failure @marcusdAIy  approved

## Summary

When driveSearchFiles hits Drive's 200-folder recursive-search limit, the Flue gateway now returns the tool's guidance ("Pass a more specific folderId") to the agent as an invalid request. It no longer reports a transient Rhodes MCP failure, and the case is no longer captured as an unexpected Platform Error.

Fixes [AERIE-2812](https://linear.app/builder-team/issue/AERIE-2812).

## Why it's needed

extractRhodesMcpToolPayload wrapped every isError: true tool result in a plain Error whose message starts with "Rhodes MCP tool returned an error". codeForGatewayError matches the substring MCP before any other rule, so a deterministic tool-level limit became rhodes_mcp_upstream_error, then transient_failure, then a 502. The 502 was captured as an unexpected Platform Error, and the agent was told the capability was temporarily unavailable. The two production occurrences were for a site whose Drive tree had 227 folders.

## Changes

- chat/convex/agentRuns/http.ts:

- extractRhodesMcpToolPayload now throws a RhodesMcpToolError, which keeps the tool's text. The message format is unchanged.

- codeForGatewayError classifies RhodesMcpToolError by that text:

- a non-retryable result that matches Drive's "exceeding the N-folder search limit" guard becomes gateway_invalid_request, a 400 that isn't captured and that returns the tool message to the agent;

- everything else, including Rhodes' structured retryable: true errors, stays rhodes_mcp_upstream_error as before.

- chat/convex/agentRuns.test.ts has two gateway tests:

- the folder-limit result returns 400 with the guidance and records no errorEvents; this test fails without the fix;

- a retryable Rhodes tool error still returns 502 transient_failure and is captured.

## Breaking changes

None. Transport failures, timeouts, JSON-RPC errors, and other tool errors are classified exactly as before.

## Test plan

- [x] chat: npx vitest run convex/agentRuns.test.ts (115 pass)

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

- [ ] After deploy: no new /agent/flue-gateway Platform Errors for driveSearchFiles folder-limit responses

#1847 — fix(task-board): allow delayed daily digest starts (AERIE-2848) @caina-barbosa  approved

## Summary

This follow-up in [Human Task Assignment from Aerie](https://linear.app/builder-team/project/human-task-assignment-from-aerie-b3582376ae6e) fixes daily digest runs that start after their scheduled minute. Tracked by [AERIE-2848 — Allow delayed daily Task digest dispatch without duplicate emails](https://linear.app/builder-team/issue/AERIE-2848/allow-delayed-daily-task-digest-dispatch-without-duplicate-emails).

Production effect: dormant/additive. Digest delivery remains disabled by its existing opt-in. Merging this PR enables no emails and changes no production configuration. Once delivery is enabled separately, a run starting at 7:31 a.m. New York time can deliver that day's digest instead of skipping it. The existing recipient/date key prevents duplicate emails.

---

## Why

The handler accepted only the exact 7:30 a.m. New York minute. A scheduler delay of one minute skipped delivery, and the second summer cron candidate at 8:30 a.m. could not catch up. Accepting starts at or after 7:30 resolves this with the existing daily delivery deduplication.

---

## Business Value

- A normal scheduler delay will not discard the day's digest.

- Each recipient keeps one queued digest for that New York date.

- Delivery remains disabled until separately enabled.

---

## How does it work

1. taskBoard/digest.sendDaily still exits immediately unless the digest opt-in is enabled.

2. A first-page invocation checks the current New York time and skips starts before 7:30 a.m.

3. Starts at or after 7:30 use the current New York date and the existing task-scenario:<date>:<user> delivery key.

4. The two UTC cron candidates remain unchanged. Existing notification reservation prevents duplicate deliveries for the same recipient/date.

5. Scheduled continuation pages retain the original logical date, including across midnight.

---

## Scope

### Included in this phase

- One scheduling condition and its cron comment.

- Focused delivery tests for delayed starts, standard/daylight time, duplicate candidates and continuation dates.

- Exact final diff paths:

chat/convex/crons.ts

chat/convex/taskBoard/digest.ts

chat/convex/taskBoard/digest.test.ts

### Deliberately excluded for later phases

- No delivery activation or production configuration changes.

---

## Test plan

### Automated validation

- Digest action tests: 8/8 passed after rebasing on latest main (pnpm --dir chat exec vitest run convex/taskBoard/digest.test.ts --maxWorkers=1).

- The delayed-start tests failed against the original exact-minute guard before the correction.

- Full Chat typecheck passed in the mandatory commit hook (./chat/node_modules/.bin/tsc --noEmit -p chat/tsconfig.json) and in hosted CI on the final head.

- Biome passed (pnpm exec biome check chat/convex/taskBoard/digest.ts chat/convex/taskBoard/digest.test.ts chat/convex/crons.ts).

- Test architecture passed (pnpm lint:test-architecture).

- Convex module paths passed (pnpm lint:convex-paths).

- git diff --check origin/main...HEAD passed; the diff contains only the three paths above.

- All hosted CI checks passed on a1dac4d6ab48d1d244cfd0d9c5f0a6bc7b97a26f, including the full Test job, Typecheck, lint and every build.

### Time for Implementation

About half a day for investigation, focused regression tests, implementation and validation without AI assistance.

#1857 — fix(rhodes): diagnosable approval-token errors and expected throttling outcome @marcusdAIy  approved

## Summary

Two fixes to how the Rhodes mutation card reports approve/reject failures:

- A failed approval-token or approve/reject call now carries the HTTP status, the request ID (from the body or the X-Request-Id header), and a short excerpt of any non-JSON body, instead of a bare fallback string.

- Upstream throttling (HTTP 429) is recorded as the expected outcome rhodes.mutation.rate_limited, with a retry message.

Fixes [AERIE-2813](https://linear.app/builder-team/issue/AERIE-2813) and [AERIE-2787](https://linear.app/builder-team/issue/AERIE-2787).

## Why it's needed

- 2813: getApprovalTokenForOverride parsed the body with response.json().catch(() => ({})). When the body wasn't the route's JSON (a proxy error page or a truncated response), the user and Platform Errors only saw "Rhodes approval token signing failed", with no status or request ID to correlate. The server-side callRhodesAerieEndpoint already keeps all three.

- 2787: postMutationAction threw a plain Error for every non-2xx, so throttling by Rhodes opened unexpected Platform Errors even though retrying is the right answer.

## Changes

- rhodes-mutation-card.tsx:

- readMutationResponse reads the body as text and keeps it only when it isn't a JSON object.

- mutationResponseError builds the thrown error for both getApprovalTokenForOverride and postMutationAction:

- a 429 becomes PlatformExpectedOutcomeError("rhodes.mutation.rate_limited") with "Wait a moment and try again";

- otherwise the route's structured error is kept as before, or the fallback gets (HTTP <status>) and a 200-character excerpt;

- the request ID falls back to the X-Request-Id header.

- packages/contracts/src/platform-errors.ts: register rhodes.mutation.rate_limited as a public expected-outcome code, so browser captures keep that disposition.

- rhodes-mutation-card.node.test.ts has three new tests:

- a 429 becomes the expected outcome with the request ID;

- a non-JSON 502 from token signing keeps the status, header request ID, and excerpt;

- a structured 400 error message is unchanged.

The first two fail without the card change.

## Breaking changes

None. Success paths and structured route errors behave as before, and the buttons stay enabled after an error, so the user can retry.

## Test plan

- [x] chat: npx vitest run components/__tests__/tool-call.test.tsx components/__tests__/rhodes-mutation-card.node.test.ts (111 pass). The card now reads responses with text(), so the tool-call.test.tsx fetch mocks build their bodies with a jsonBody() helper that also provides text() and headers.

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

- [ ] After deploy: no new unexpected rhodes_approve events for 429s, and any new token-signing failure includes a status and request ID

#1858 — fix(portfolio): surface Due Diligence draft conflicts to the client as ConvexErrors @marcusdAIy  approved

## Summary

Due Diligence draft conflicts now reach the browser intact. The UI's existing "refresh the draft" handling runs instead of a generic Convex "Server Error", and the conflict is recorded as an expected outcome. Fixes [AERIE-2807](https://linear.app/builder-team/issue/AERIE-2807).

## Why it's needed

createRequestForActor and the Aerie patch path throw the sentinel-encoded dueDiligenceDraftConflictMessage(...) when a pending DD request already exists or has changed. They threw a plain Error, and Convex redacts plain errors from public functions in production, so writePortfolioDueDiligence callers only saw "Server Error". The provider's parseDueDiligenceDraftConflictMessage never matched, and each retry opened another unexpected Platform Error (3 in about 37 seconds for one user). convex-test doesn't redact, so the existing action test couldn't catch this.

## Changes

- fieldChangeRequests.ts: the four draft-conflict throws use the existing userError() helper, which produces a ConvexError with string data, the codebase's way to send a safe message to the client. The message text is unchanged, so the v2 classifyDueDiligenceWriteError mapping and the Rhodes write path behave as before.

- portfolio-fields-provider.tsx: parse the conflict from toUserMessage(err), which reads ConvexError.data verbatim instead of Convex's framed message. When it is a conflict, capture it with disposition: "expected_outcome" and outcomeCode: "due_diligence_draft_conflict".

- packages/contracts/src/platform-errors.ts: register due_diligence_draft_conflict as a public expected-outcome code.

- Tests:

- the existing action conflict test in dueDiligence.test.ts now asserts a ConvexError and parses its data;

- a new provider test rejects with a production-shaped ConvexError, with the framing and stack line in message. It asserts the dialog gets the clean conflict text, retry is disabled, and the capture is an expected outcome. This test fails without the provider change.

## Breaking changes

None. The conflict message text is the same, now delivered unredacted.

## Test plan

- [x] chat: npx vitest run components/dashboards/portfolio/fields/__tests__/portfolio-fields-provider.test.tsx convex/portfolio/dueDiligence.test.ts convex/publicApi/v2/writeErrorClassification.test.ts (155 pass)

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

- [ ] After deploy: saving DD while a pending request exists shows the refresh-the-draft message, not "Server Error"

Note: locally, 2-3 milestone tests in convex/portfolio/fieldChangeRequests.test.ts time out in finishAllScheduledFunctions on clean main too, so they're unrelated to this change.

#1855 — fix(portfolio): record Portfolio Fields network-layer fetch failures as expected outcomes @marcusdAIy  approved

## Summary

When the browser can't reach the server at all ("Failed to fetch"), Portfolio Fields now records the failure as an expected outcome instead of an unexpected Platform Error. The user still sees the same error banner or dialog message. Fixes [AERIE-2849](https://linear.app/builder-team/issue/AERIE-2849).

## Why it's needed

fetch rejects with a TypeError only when no response was obtained: offline, a dropped connection, or an interrupted navigation. No server code ran, so there's nothing to fix, but the provider's catch blocks sent every throw to capturePortfolioError with the default unexpected disposition. This is the browser-side version of [AERIE-2829](https://linear.app/builder-team/issue/AERIE-2829).

## Changes

- portfolio-fields-provider.tsx: a fetchFieldsRoute wrapper turns a fetch TypeError into PlatformExpectedOutcomeError("portfolio.fields.network_unavailable"). It's used by the three /fields calls the ticket names: mount fetch, revalidate-before-save, and save. Only the fetch call itself is wrapped, so non-2xx responses and unreadable response bodies are still captured as unexpected.

- packages/contracts/src/platform-errors.ts: register portfolio.fields.network_unavailable in PLATFORM_ERROR_PUBLIC_EXPECTED_OUTCOME_CODES. The public capture mutation only keeps an expected_outcome disposition for registered codes.

- Tests: the existing network-rejection tests for all three paths now also assert the captured disposition and outcome code; all three fail without the provider change. A new test checks that an unreadable 200 response on mount is still captured as unexpected.

## Breaking changes

None. User-facing messages are unchanged.

## Test plan

- [x] chat: npx vitest run components/dashboards/portfolio/fields/__tests__/portfolio-fields-provider.test.tsx (73 pass)

- [x] chat: npx vitest run convex/platformErrors lib/platform-errors (69 pass)

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

- [ ] After deploy: no new unexpected mount_fetch / Failed to fetch events on Portfolio Fields

#1856 — fix(portfolio): soft-fail the fields route current-user lookup @marcusdAIy  approved

## Summary

The Portfolio site fields route now uses the shared, soft-failing current-user helper. A transient identity-query failure gives a read-only page instead of a bare 500 that blanks the site detail view. Fixes [AERIE-2782](https://linear.app/builder-team/issue/AERIE-2782).

## Why it's needed

/api/portfolio-sites/[slug]/fields soft-fails all of its other optional lookups (active users, enrollment, reconciliation evidence, pending changes). But its own local getCurrentUser called fetchQuery(api.users.identity.currentUser) without a try. When that query threw, nothing caught it, so Next returned a non-JSON 500 and the provider showed its generic "Failed to load site" banner. The sibling routes already use getCurrentUserFromConvexToken from chat/lib/current-user-server.ts, which logs and returns null.

## Changes

- route.ts: remove the local getCurrentUser and call getCurrentUserFromConvexToken(convexToken) instead. The route's narrower CurrentUser type is kept, so the permission checks are unchanged.

- route.node.test.ts: a new test makes the current-user query throw. GET still returns 200 with the site and every canEdit* flag false, and the shared helper's warning is logged. This test fails without the route change.

## Breaking changes

None. A failed lookup now behaves like an unrecognised user: GET returns a read-only view, and POST returns the existing 403 "Forbidden" instead of a bare 500.

## Test plan

- [x] chat: npx vitest run "app/api/portfolio-sites/[slug]/fields/__tests__/route.node.test.ts" (64 pass)

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

- [ ] After deploy: no new opaque 500s for this route's mount_fetch

#1853 — fix(api-v2): classify admissions person-refs readiness 503 as an expected outcome @marcusdAIy  approved

## Summary

The by-design Public API v2 readiness 503 admissions_person_refs_not_ready is now captured as an expected outcome instead of an unexpected Platform Error. Callers get the same 503 as before. Fixes [AERIE-2840](https://linear.app/builder-team/issue/AERIE-2840).

## Why it's needed

Admissions person reads (funnel students, student search, event contacts) fail closed with this code until the migrations/admissionsPersonPublicRefs backfill has been verified and activated. publicApiV2PlatformOutcome only treats a 503 as expected when its code is in EXPECTED_READINESS_CODES, which already covers the equivalent site-notes, directory, and work-management gates but not this one. So every gated call opened an unexpected Platform Error: 23 so far on listAdmissionsFunnelStudents.

## Changes

- Add admissions_person_refs_not_ready to EXPECTED_READINESS_CODES in chat/convex/publicApi/v2/platformOutcomes.ts.

- Tests:

- the parametrized readiness cases in platformOutcomes.test.ts gain the code;

- the existing fail-closed HTTP test in admissions.test.ts now also asserts that the captured event has disposition: "expected_outcome" and the matching outcomeCode. That assertion fails without the allowlist change.

## Breaking changes

None. The response status, code, and body are unchanged.

## Test plan

- [x] chat: npx vitest run convex/publicApi (37 files pass)

- [x] Pre-commit biome + typecheck-chat

- [ ] CI

- [ ] After deploy: no new unexpected events for this code

#1852 — fix(admissions): polish pipeline report headers @vvp-trilogy  approved

## Testing contract

### What this PR delivers

Polishes the Admissions Pipeline report header so group boundaries read consistently, the Leads contact-grain chip is centered, and pipeline terminology/tooltips use approved plain language.

### Who uses it and where

Admissions dashboard users viewing the Pipeline report on desktop or mobile.

### Conditions needed

Open Dashboards > Admissions > Pipeline with pipeline data available. Desktop width is needed to inspect grouped table headers; mobile uses the existing card layout.

### Expected behavior and examples

- Group headers no longer show isolated horizontal accent borders.

- Vertical group dividers extend to the top of the table header.

- The parent contacts chip is centered under the Leads label.

- Info Session / Tour is labeled Educated Leads (MQL) in the KPI, table/card labels, and CSV.

- Leads and Educated Leads explain that no school-year association exists and the year filter is not applied.

- Community tooltips identify the Community application platform without internal source-system or stat-exclusion language.

- Application uses a plain-language tooltip without the CoG acronym.

### Limits and unanswered questions

This is presentation and copy only; pipeline stage assignment and counts are unchanged.

### Validation

- pnpm --dir chat exec vitest run components/dashboards/admissions/pipeline/pipeline-matrix.test.tsx components/dashboards/admissions/pipeline/derivation.node.test.ts --maxWorkers=1

- pnpm typecheck

- pnpm biome check chat/components/dashboards/admissions/pipeline/derivation.node.test.ts chat/components/dashboards/admissions/pipeline/pipeline-matrix.test.tsx chat/components/dashboards/admissions/pipeline/pipeline-matrix.tsx chat/components/dashboards/admissions/pipeline/pipeline-view.tsx packages/contracts/src/admissions-pipeline.ts

#1838 — feat(document-knowledge): read native Google Sheets through the Sheets API (AERIE-2842) @marcusdAIy  approved

## Summary

Native Google Sheets are now indexed by reading their cell values through the Sheets API instead of exporting the file from Drive. Image-heavy Sheets over Drive's 10 MB export limit, like POSH's FF&F catalog, can then be indexed without a values-only mirror. The Rhodes Worker's service account (sheets-download@silver-charmer-443908-f2, in GCP project silver-charmer-443908-f2) is the account Klair already uses to read Google Sheets, so the Sheets API should already be enabled on that project and no setup should be needed. If it isn't, every Sheet falls back to today's export path and nothing changes in prod.

Closes AERIE-2842

## Why it's needed

The indexer reads native Sheets through Drive's XLSX export, and Drive refuses any export over 10 MB. That includes embedded images, so an image-heavy Sheet can't be indexed even when its text is small. The FF&F catalog (a 374 MB native Sheet) failed this way, and POSH had to build a mirror by hand. Since [#1836](https://github.com/AI-Builder-Team/Aerie/pull/1836), those Sheets at least report too_large; this PR lets them index.

## Changes

- New rhodes-worker/lib/document-knowledge/sheets.ts:

- spreadsheets.get lists the grid tabs with their sizes. Chart tabs are skipped.

- values.get reads each tab's displayed values in row blocks sized to the tab's width (about 200k cells per request).

- Output is the same records as the XLSX path: one per tab, Sheet: <name> followed by rows of | -joined cells.

- It uses the same text budget, so an oversized Sheet still fails too_large.

- Each response is capped at 8 MiB. A block over that is retried at half the rows, down to 50 rows.

- Blank cells and formula error values are dropped, matching the XLSX path.

- drive-reader.ts: adds getSpreadsheet and getSheetValues. A 403 whose reason is SERVICE_DISABLED or accessNotConfigured becomes SHEETS_API_DISABLED.

- retrieval.ts:

- Native Sheets try the Sheets API first.

- On SHEETS_API_DISABLED or any other 403, it logs a warning and falls back to the Drive XLSX export, which then decides whether the file is really inaccessible. That way an unrecognised "API disabled" response can't turn every Sheet into "access denied".

- 404s, 429s and other failures classify the same way as Drive failures.

- The source hash covers the rendered text.

## Breaking changes

None.

- If the Sheets API turns out not to be enabled: Sheets keep using the export, with one extra refused request per Sheet and a Sheets API refused the read warning in the Worker logs.

- Once the Sheets path is in use: existing Sheets re-render their values as displayed (for example $1,200.00, or dates in the sheet's own format, where the XLSX path wrote raw numbers and ISO dates). Their text changes slightly the next time each Sheet is reindexed.

## Test plan

- [x] sheets.test.ts: paged reads across tabs (including a tab name with an apostrophe), retrying a block at a smaller size, too_large when even the smallest block is too big, the text budget across tabs, blanks/errors/newlines, no readable text, and non-2xx metadata or values responses classifying instead of reading as empty or partial sheets

- [x] retrieval.test.ts: the Sheets API read produces the same records as the XLSX export of the same two-tab workbook; it falls back to the export on SHEETS_API_DISABLED and on PERMISSION_DENIED; NOT_FOUND and RATE_LIMITED classify without falling back

- [x] drive-reader.test.ts: both "API disabled" 403 shapes map to SHEETS_API_DISABLED, and the request URLs are encoded correctly

- [x] pnpm test (322 tests) and pnpm typecheck in chat/rhodes-worker

- [x] Pre-commit hooks: biome and typecheck-chat

- [x] CI

- [ ] After deploy: Reindex the original FF&F catalog and confirm it becomes searchable (if it still fails too_large, check the Worker logs for Sheets API refused the read)

#1845 — fix(reconciliation): make commit proposals receipt-owned (AERIE-2837) @caina-barbosa  approved

## Summary

This PR is the blocking repair in [AERIE-2653 — Complete all remaining production observe cohorts and close the rollout](https://linear.app/builder-team/issue/AERIE-2653/complete-all-remaining-production-observe-cohorts-and-close-the).

It makes the Commit Proposal boundary receipt-owned: the Agent returns only its five decision/evidence fields, while Aerie restores contractVersion, targetRef, and the complete input fence from the persisted receipt before applying the existing authoritative validation. It also explicitly sets the Commit Proposal Agent's optional model Quality Bar to null while retaining Sindri Output Format validation. This work is tracked by [AERIE-2837 — Make Commit Proposal receipt-owned and remove its model Quality Bar](https://linear.app/builder-team/issue/AERIE-2837/make-commit-proposal-receipt-owned-and-remove-its-model-quality-bar).

Production effect: controlled rollout or migration. Merging changes the accepted Commit Proposal wire contract, but it does not publish Sindri assets, start reconciliation, enable writes, clear the ANDON, or resume the rollout. Deployment and asset publication are separate, coordinated actions.

---

## Why

The Boston observe run failed because an Agent-carried copy of otherwise-valid receipt metadata omitted one type: "string". The Carrollton proposal passed Aerie's deterministic validator but was subsequently vetoed by a malformed optional model Quality Bar verdict. This repair removes both non-authoritative failure modes without weakening receipt fencing, citation validation, stale-work checks, canonical hashing, replay idempotency, or observe-mode no-write behaviour.

---

## Business Value

- Prevents model copying errors from corrupting Site identity, allowed-field schemas, or source fences already held by Aerie.

- Prevents an optional model judge from overriding Aerie's deterministic proposal validator.

- Keeps stored proposals fully auditable because their canonical representation still contains the complete receipt-bound context.

- Preserves observe-mode safety and existing commit-mode approval and kill-switch controls.

---

## How does it work

1. @bran/contracts defines an exact Agent-owned proposal containing only sourceAssessments, operations, dispositions, citations, and decision.

2. bindReconciliationAgentProposalV1 rejects legacy envelope fields and unexpected keys, then combines the Agent decision with Aerie's authoritative receipt envelope.

3. Draft validation and final settlement both use the same receipt-bound validation seam before existing citation, policy, freshness, lease, and canonical-hash checks run.

4. Aerie continues storing the complete canonical proposal and hash; existing stored full proposals remain readable and require no migration.

5. The Commit Proposal Agent and Workflow assets request the five-field shape, retain required Output Format validation, and explicitly set successCriteria: null.

6. Asset publication, production registration changes, deployment, and rollout resumption remain deliberately inactive.

---

## Scope

### Included in this phase

- Exact five-field Commit Proposal Agent input contract.

- Authoritative receipt-envelope binding shared by draft and final validation.

- Strict rejection of legacy, mixed, nested-stringified, and unknown Agent proposal shapes.

- Preservation of complete canonical proposal storage and terminal replay behaviour.

- Explicit removal of only the Commit Proposal model Quality Bar.

- Updated Commit Proposal Agent, Skill, and Workflow assets.

- Regression and local write-disabled Boston/Carrollton coverage.

- Exact final diff paths:

chat/convex/reconciliation/finalOutputSettlement.test.ts

chat/convex/reconciliation/finalOutputSettlement.ts

chat/convex/reconciliation/readiness.ts

chat/convex/reconciliation/validator.test.ts

chat/convex/reconciliation/validator.ts

chat/convex/reconciliation/validatorHttp.test.ts

chat/convex/reconciliation/validatorHttp.ts

chat/sindri-assets/document-field-reconciliation/agents/commit-proposal.json

chat/sindri-assets/document-field-reconciliation/assets.test.ts

chat/sindri-assets/document-field-reconciliation/skills/aerie-reconciliation-commit-proposal/SKILL.md

chat/sindri-assets/document-field-reconciliation/workflow.json

packages/contracts/src/reconciliation.test.ts

packages/contracts/src/reconciliation.ts

### Deliberately excluded for later phases

- Publishing or mutating any Sindri Agent or Workflow version.

- Updating the production Workflow registration or retrying a historical run.

- Deployment, production starts, write enablement, ANDON clearance, or rollout continuation.

- Changes to the Evidence Analyst or Site Reconciler contracts.

- Sindri runner changes, schemas, migrations, backfills, new endpoints, or new MCP tools.

- Reviving AERIE-2668 through AERIE-2671 or the retired source-relevance classifier design.

- Reimplementing the two citation-organisation suggestions that existed only in the model judge.

---

## Test plan

### Automated validation

- Contracts reconciliation tests — 15/15 passed (cd packages/contracts && pnpm exec vitest run src/reconciliation.test.ts --reporter=verbose).

- Commit Proposal asset tests — 7/7 passed (cd chat && node --test sindri-assets/document-field-reconciliation/assets.test.ts).

- Focused validator, validator HTTP, and settlement tests — 35/35 passed (cd chat && pnpm exec vitest run convex/reconciliation/validator.test.ts convex/reconciliation/validatorHttp.test.ts convex/reconciliation/finalOutputSettlement.test.ts).

- Full Chat reconciliation edge project — 131/131 passed during final independent review.

- Local isolated E2E — 61/61 intended checks passed across saved-case replay, explicit-null runner behaviour, observe settlement, exact replay, focused tests, and assets.

- Contracts typecheck — passed (pnpm --dir packages/contracts typecheck).

- Chat and Convex typecheck — passed (pnpm --dir chat typecheck).

- Rhodes worker typecheck — passed (pnpm --dir chat/rhodes-worker typecheck).

- Architecture boundaries, Convex paths, read bounds, and test architecture — passed.

- git diff --check — passed.

- Independent review — PASS for exact commit 8f0db85ba2dee36d7516abf4d12cb2a7adaafc39.

A mistakenly broad local contracts invocation also ran unrelated tests. The target reconciliation file passed, while an unrelated large-bundle stress test timed out at its existing 5-second limit. It was not changed or rerun; the corrected target-only command passed 15/15. Hosted CI remains the final full-suite gate.

### Time for Implementation

Approximately 4–6 engineering days without AI assistance, including failure analysis, contract design, implementation, regression coverage, independent review, saved-case replay, and rollout-safety validation.

### Manual QC

A local, isolated, write-disabled E2E replay used the supplied private Boston and Carrollton saved inspections without production credentials, networks, or model calls.

- Boston: the saved receipt retained both string schemas while the Site Reconciler handoff omitted one. The old full-proposal path rejected the corruption; candidate receipt binding restored type: "string" from Aerie's receipt. The exact historical Commit Proposal validator request was not retained, so this is explicitly not represented as an exact replay of that missing payload.

- Carrollton: both captured proposals were reduced to the five Agent-owned fields, rebound to the authoritative envelope, and reproduced canonical hash d5fee920068a6a4ce80884f98a392cf1096ec35d2d4e3a5e9eeff1eb2cf2117b.

- Explicit successCriteria: null: Output Format still accepted valid output and rejected malformed output; generic judge calls were zero.

- Observe settlement and exact replay: five terminal invocations completed with no_write; Site changes, field-history rows, provenance rows, scheduled functions, outbound calls, and duplicate proposal/citation rows were all zero.

The redacted E2E report SHA-256 is 7e1486dcf55bda8cdd107be65d2c71bec06215d8c9eadef2a4271a7057cdd2dc. Private production-derived fixtures were not committed.

---

## Limits and unanswered questions

- The exact historical Boston Commit Proposal validator request was not retained. Saved evidence directly proves the valid-receipt/corrupt-handoff mismatch; the candidate repair used a minimal five-field decision over that saved envelope.

- The new five-field Agent contract and Aerie deployment require a coordinated cutover because candidate Aerie intentionally rejects the old eight-field Agent output.

- Sindri asset publication, production registration updates, and rollout resumption require separate authorization after merge and deployment.

- This exact validated head is based on 2f18213cea317c2f2010ee927ae7aa819e7e1dec; main has advanced. Before merge, the branch must be updated and the exact-head merge gates rerun. Do not treat the current local E2E as evidence for a different rebased or merged SHA.

#1843 — fix(tasks): keep controls visible while task results refresh @caina-barbosa  approved

Applying a task filter could replace the entire page with “Loading Tasks”, hiding the search and filter controls. Task utility changes now update the URL through native history, which Next.js synchronizes without navigating the page.

My Tasks keeps its controls mounted while resetting pagination, and both views retain their counters during refresh. Only the task list shows loading skeletons, with an accessible loading announcement and reduced-motion support.

Validation: 37 focused UI tests passed across My Tasks, Team Tasks, and shared list controls; Chat TypeScript checking and Biome passed. Regression coverage checks that controls remain mounted during loading and filters update history without page navigation.

#1844 — Admissions Pipeline: separate Community group @vvp-trilogy  approved

## Summary

- add a standalone communityCommitment pipeline group labeled Community between Leads and Active Applications

- style the new header consistently on desktop and mobile and remove stale row-total wording from tooltips

- verify group ordering, membership, and mobile rendering while preserving Community Commitment data semantics

## Validation

- pnpm --dir packages/contracts exec vitest run src/admissions-pipeline.test.ts --maxWorkers=1

- pnpm --dir chat exec vitest run --project node components/dashboards/admissions/pipeline/derivation.node.test.ts --maxWorkers=1

- pnpm --dir chat exec vitest run --project browser components/dashboards/admissions/pipeline/pipeline-matrix.test.tsx --maxWorkers=1

- pnpm --dir packages/contracts typecheck

- pre-commit Chat typecheck

- pnpm lint:test-architecture

- pnpm lint:boundaries

- scoped Biome check

Closes #1842

#2158 — feat(ai-spend-reconciliation): send the monthly report to Finance and budget leads @kevalshahtrilogy  approvedheimdall-driven

Linear: [SURTR-1622](https://linear.app/builder-team/issue/SURTR-1622)

## What changed

RECON_EMAIL_RECIPIENTS in ai-spend-reconciliation/pipeline.json goes from Keval alone to Keval, David Harpur, Ravi, Jamie, Colin and Sandeep, as David asked on the Q4 budget thread (2026-10-13). Config only; no code change.

## Testing

- uv run pytest in the pipeline: 97 passed.

- npx jest test/real-pipeline-configs.test.ts in pipelines/cdk: 611 passed.

- The 2026-09 report was already invoked once and delivered to Keval, so the SES send path is proven.

## After merge

The next scheduled run (2026-11-05 08:00 UTC) is the first to reach the wider list.

## Business Value

Puts the monthly statement of where Klair's AI spend disagrees with vendor billing in front of the people who own the budget, which is what David Harpur asked for on 2026-10-07. Without it only one engineer sees the report.

## Manual Effort Estimate

About 15 minutes by hand (config edit, tests, PR). Keval to confirm or adjust.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#1839 — feat(dbt): add per-level admissions enrollment forecast (#1837) @vvp-trilogy  approved

## Summary

- Adds a per-level (student_level) companion to the existing program-level Session 3 January admissions forecast. New model int_admissions_forecast_by_level / mart mart_admissions_forecast_by_level replicate the Session 3 formula independently per canonical level (never a proportional share of the program headline), reading ONLY published marts (mart_admissions_forecast, mart_admissions_pipeline_dtl, mart_enrollment_dtl, int_admissions_conversion_rates) — it intentionally does NOT replicate the program-level temporal-locking layer (int_admissions_forecast_resolved), which stays out of scope.

- Adds a governed level-resolution layer: student_levels (7-value canonical domain) and student_level_aliases (every source system's raw grade/native-level value, scoped by (source_system, source_field, mapping_type)) seeds, with two equivalent resolution macros — a correlated-scalar-subquery form (resolve_student_level, for untested call sites) and a join-based form (student_level_lookup_join, required wherever a model carries a dbt unit test, or feeds a downstream union all, since Redshift's planner rejects the scalar-subquery shape once combined with a union anywhere in the compiled plan — confirmed directly against Redshift, including via view-inlining of int_finalsite_pipeline into int_admissions_forecast_detail's union).

- Adds int_admissions_forecast_level_grade_operands / mart_admissions_forecast_level_grade_operands: an audit/reconciliation grain (by program/year/raw_grade/student_level/resolution_basis/operand) that cross-checks the new level forecast's inputs against the grade-level operands they're derived from, plus assert_forecast_level_grade_operands_reconcile.

- Threads student_level/student_level_resolution_basis through int_admissions_deal, int_educrm_community_commitment, mart_admissions_pipeline_dtl, and mart_admissions_forecast_dtl so every pipeline/enrollment source carries a resolved level. For Community Commitment (EduCRM, no native level field), resolution prefers a matched HubSpot deal's already-resolved level (deal-id match; ~58% of Community Commitment deals match live), falling back to the stage's own enrollment_grade via grade_fallback when no deal matches or the matched deal itself resolved to UNKNOWN. int_admissions_deal is confirmed single-portal (alpha, cardinality = 1) today, so the join carries no portal_name qualifier.

- The KEPT Austin-only grade-pair audit model (int/mart_admissions_forecast_grade_operands) and its macro (forecast_grade_operand_rows) are untouched; a parallel forecast_level_grade_operand_rows macro was added instead so the by-level audit grain doesn't disturb that model's fixed shape.

- Shared aggregate macros (forecast_enr_agg, forecast_detail_agg, forecast_pipeline_agg, forecast_neutral_enr_agg) gained a by_level parameter. raw_level_basis only joins the GROUP BY when by_grade is ALSO requested (the grade-operand audit model's intentional basis-inclusive grain) — grouping by basis under a level-only caller would fan out one raw_level into multiple rows whenever a program/year/level has records resolved via both native_level and grade_fallback, which is exactly the uniqueness-test regression this fix resolves in int_admissions_forecast_by_level.

## Build verification

- Full local build: poetry run dbt build --select path:models path:seeds --vars '{pr_number: 1837}' → PASS=613 WARN=7 ERROR=1 SKIP=159.

- The single ERROR (assert_admissions_pipeline_enrollment_date_unambiguous, "Got 2 results") is a pre-existing, unrelated Finalsite source-data anomaly: two plano-alpha people carry genuinely conflicting hubspot_enrollment_date attribute values. Verified directly against the unprefixed production table (stg_finalsite_custom_attributes) on main — same 2 rows, same person_ids, independent of this PR's diff (the test file is untouched and reads only that staging table).

- That failure's SKIP cascade (159 nodes, including this PR's own 4 new level-forecast objects plus several pre-existing out-of-scope forecast models) is a mechanical consequence of mart_admissions_pipeline_dtl's build being skipped in that same run — not a defect introduced here.

- All 4 new objects were independently verified end-to-end via a targeted build against the existing mart_admissions_pipeline_dtl relation: poetry run dbt build --select int_admissions_forecast_by_level+ int_admissions_forecast_level_grade_operands+ --vars '{pr_number: 1837}' → PASS=36 WARN=0 ERROR=0 SKIP=0, including both grain-uniqueness tests.

- The 7 WARNs are pre-existing (assert_sis_application_pipeline_type_domain, assert_sis_campus_unresolved_hubspot_program, data_quality_sis_duplicate_enrollments, and others) — none touch files in this diff.

- pnpm typecheck and pnpm biome check . both pass; the 2 biome warnings present (chat/skill/forge-api/scripts/sindri.mjs) are in a file untouched by this diff.

- git diff --stat against origin/main shows only dbt/** files.

- The scratch pr1837_* schema objects created during local verification were dropped after the build (confirmed zero remain in sandbox_education).

## Self-review audit

- Reverted one piece of scope creep from an earlier working segment: forecast_ec.sql had gained unused raw_level/raw_level_basis columns that its only caller (int_admissions_forecast.sql, untouched) never reads — restored to its original form (zero diff vs origin/main).

- Fixed a genuine fan-out bug introduced during this PR's own development (not present in the final diff): an earlier draft of int_admissions_forecast_by_level joined level-and-basis-grouped aggregates back to the level spine using only raw_level as the match key, producing duplicate rows whenever a program/year/level resolved via both native_level and grade_fallback. Fixed by decoupling the basis column from the by_level-only grouping path in the shared macros (see Summary) rather than special-casing the model.

- No worker/Convex/UI/API files touched — this is a dbt-only change.

## Open questions / risks for reviewers

- An earlier working segment of this task (now corrected) briefly printed a Redshift credential value to a log/output; it was not committed anywhere and credentials were re-fetched fresh afterward, but flagging for visibility since it touched the same sis-dev Secrets Manager credential used here.

- The pre-existing assert_admissions_pipeline_enrollment_date_unambiguous failure (2 plano-alpha people with conflicting enrollment dates) is a real Finalsite source-data defect worth a separate data-quality ticket; not fixed here since it's out of scope and unrelated to this diff.

- int_admissions_deal's level-resolution join assumes single-portal cardinality (alpha, confirmed = 1 today); if a second HubSpot portal is ever onboarded to that model, the join should gain an explicit portal_name predicate.

- The second reconciliation check (program-level headline vs. summed per-level headline) was considered but not added — the existing assert_forecast_level_grade_operands_reconcile plus the grade-operand audit grain already cross-check the level forecast's inputs; a program-vs-sum-of-levels check would be a reasonable follow-up if reviewers want tighter headline-parity coverage.

## Test plan

- [x] Full local dbt build (path:models path:seeds) reviewed; sole ERROR confirmed pre-existing/unrelated, sole SKIP cascade traced to that same pre-existing issue.

- [x] Targeted build of the 2 new intermediate models + everything downstream: 36/36 PASS.

- [x] pnpm typecheck clean.

- [x] pnpm biome check . clean (pre-existing warnings outside this diff only).

- [x] git diff --stat confirmed dbt/**-only.

- [x] pr1837_* scratch schema objects cleaned up.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#1832 — Improve Task Site document linking with search @caina-barbosa  approved

## Summary

- replace the flat Site document list with an on-demand searchable picker

- search document titles server-side within the Task Site using a bounded Convex search index

- show linked state, loading/empty states, and paginated results in a responsive popover

## Validation

- pnpm exec vitest run components/task-board/task-documents-panel.test.tsx --maxWorkers=1

- pnpm exec vitest run convex/taskBoard/documents.test.ts --maxWorkers=1

- pnpm lint:read-bounds

- scoped Biome and full Chat typecheck via pre-commit hook

- live browser clickthrough at desktop and 390x844 mobile; no console errors

#3867 — fix(renewals): refine flat labels and backend logging @mwrshah  approved

- Shorten Renewals Flat column labels, use lowercase filter placeholders, and leave the Sources filter blank and disabled.

- Reduce shared opportunity-stage chip text to text-xs and let the Flat Expiring column size naturally.

- Reuse backend rotating file handlers across loggers to prevent the too-many-open-files startup failure.

#2141 — feat(q22): add Bills, Vendor Credits, Deposits and deprecate legacy marts @sanketghia  changes requested

## Summary

This PR delivers the Q22 lifetime CAPEX mart and the Finance-requested Bills, Vendor Credits, and Deposits source additions, while marking the older site CAPEX marts as deprecated for new consumers.

- Publishes separate Q22 lifetime CAPEX detail, school aggregates, run receipts, and source findings from the latest accepted QuickBooks snapshot.

- Adds direct-account Bill AccountBasedExpenseLineDetail, VendorCredit AccountBasedExpenseLineDetail, and Deposit DepositLineDetail Fixed Asset lines. Bills are positive; VendorCredit and Deposit credits are negative. The procedure also validates extraction lineage, duplicate keys, line expansion, direct account references, and USD currencies.

- Keeps item-based Bill/VendorCredit lines out of the Finance direct-account tie-out because their historical account would have to be inferred through today's Item setup.

- Preserves the existing Purchase/JournalEntry leg and the controlled 62700 Core leg, including allocation exclusions and durable findings.

- Adds the guarded local Q22 runner and preflight checks for freshness, snapshot lineage, and mapping alignment.

- Marks mart_education.agg_school_capex_summary and mart_education.agg_school_capex_txn_detail deprecated in canonical comments and an independent forward migration. Adds a standalone backup script for those legacy tables. The legacy writer and schedule remain active pending consumer cutover; Q22 is not a drop-in replacement for the legacy site, budget, or NetSuite semantics.

## Verification

- Q22 runner tests: 26 passed.

- Legacy CAPEX runner tests: 78 passed.

- Latest accepted snapshot preflight passed with zero unaccepted raw extractions and zero stale mappings.

- On the accepted snapshot, the new Fixed Asset source projection produced 2,664 Bill lines / $44,421,936.36, 49 VendorCredit lines / -$2,155,728.34, and one Deposit line / -$4,467.43.

## Live operational status during PR preparation

- The stored procedure was replaced in Redshift as sanket.ghia; owner and CQL_download_OM execute grant were verified.

- A guarded local Q22 refresh published run q22-local-7e9aeeb3cc894b8787ec28c2ed4f6dc6 on snapshot d892c287-31a4-432b-bb32-ef3014ac2721: 13,432 Fixed Asset lines / $58,706,807.30; 226 included 62700 lines / $9,189,369.83; total lifetime CAPEX $67,896,177.13; 13,730 detail rows, 221 aggregate rows, zero findings.

- Before that refresh, the independent Q22 backup script created and verified 27 sandbox_finance copies with suffix _bak_q22_20261008_053659.

- The independent legacy backup script created and verified backups of 41 summary rows and 2,593 detail rows with suffix _bak_legacy_capex_20261008_054505.

- The legacy deprecation migration is included but remains unapplied. Attempts as sanket.ghia and CQL_download_OM failed because the live tables are owned by marcus.day; the transaction rolled back. Apply the migration as that table owner or a superuser.

## Follow-ups

- The requested QuickBooks file-to-school attribution is not implemented here. The latest Q22 output still has $52,925,743.50 across 10,699 Fixed Asset lines in unmapped_class. A follow-up must attribute school-owned files by the governed company-to-school link and use class mappings only for shared/central files. The Core P&L source also participates in the 62700 leg, so this follow-up needs coordinated Core and Q22 attribution behavior.

- Q22's Core-success trigger still needs production deployment/verification. The Finance review requested two consecutive successful daily runs.

- Per-seat CAPEX remains out of scope because no seat-capacity source is available.

#3868 — fix(mfr): prioritize cash flags for TB entries @sanketghia  approved

## Summary

- Apply cash-over-TB precedence to Book Value Other Investments and Passive Investments Cash Flows.

- Keep the Financial Statements Cash Flow path's reversal exclusion unchanged.

- Align Book Value Schedule A summaries and drilldowns with the updated inclusion rule.

Finance confirmed the report scope for this change.

## Validation

- uv run ruff format ... — no files changed.

- uv run ruff check ... — passed.

- uv run pyright ... — 0 errors, 14 warnings.

- Read-only query through the updated Klair services confirmed the scoped output changes and that Financial Statements Cash Flow remains unchanged.

## Screenshot

- Confirmed with stakeholder (Milo)

<img width="1540" height="549" alt="image" src="https://github.com/user-attachments/assets/e7199f33-df08-48fb-878c-addaa367669d" />

#3863 — feat(ai-spend): credit keyless Claude Code dollars to the person in the BU allocation @kevalshahtrilogy  approved

Linear: KLAIR-3594 (slice 3 of 3; slice 1 = #3861 merged, slice 2 = #3862)

## What

The shared Anthropic billed-dollar allocation block (AICostsService._anthropic_billed_allocation_cte) used by the BU dashboard, Budget tab, TF day-factor and weekly emails now splits the keyless Claude Code dollars across the people who ran them — the same method as the fct_ai_spend mart (slice 2) and the slice-1 view, so every surface agrees.

- Keyless pool = NULL-api_key_id rows from allocation tiers 0–2 (usage-backed), per (day, org bu, model). Residual per person = per-user report − their own claude_code_key_* usage, weights 1/5/0.1/1.25, share = w / GREATEST(Σw, pool_weight). Report's estimated cost never used.

- Person rows carry api_key_id = email and bu = directory BU (exact email, then unique local part, else the org BU), so an (anthropic, email) override applies through the existing _get_anthropic_override_join with no consumer change. The unexplained share stays on the original keyless rows (scaled), so dollars are conserved per cell.

- Sales-tax uplift still applies at the terminal CTE; TF routing untouched (all keyless rows are non-TF).

- Param list grows from 6 to 12 date params (three new windowed CTEs); every caller composes cte_params + …, tests updated. The kl_reported CTE text lives in a module constant because the Claude.ai drift guard forbids the usage_date literal in the class source.

## Validation (read-only, prod, 2026-07-01..2026-09-30, real block via Data API)

| | main | this PR |

|---|---|---|

| total allocated | 8,324,496.15 | 8,324,496.14 |

| Trilogy-Inc | 6,156,642.75 | 5,995,578.31 |

| Academics | — | 159,933.73 (72 person rows) |

Trilogy-Inc drops $161.1k = the person-row total; Alpha Anywhere, IgniteTech, etc. get small shares; other BUs unchanged.

## Tests

Five executable SQLite tests (conservation, unexplained remainder, no-report no-op, directory BU fallbacks, personal-key subtraction) on top of the existing allocation harness (fixtures gained email_local, the Claude Code usage table, and SPLIT_PART/GREATEST shims). 802 passed across the AI cost / spend suites and tests/mart_saas_metrics.

## Rollout

Service-only, deploys with the normal pipeline. Slice 2's proc apply should land right after this reaches the target environment so the People tab (mart) and the BU views agree; before the apply the People tab simply still shows the keyless dollars on Trilogy-Inc.

## Business Value

Closes KLAIR-3594: the ~$160k+ per quarter of account-signed-in Claude Code spend stops hiding on Trilogy-Inc and shows up in the budgets, dashboards and weekly emails of the BUs whose people incurred it — and the same dollars by person on the People tab — with the bill tie-out unchanged.

## Manual Effort Estimate

Proposed: ~1.5 days (porting the split into the service CTE, param/test churn, prod validation). Keval to confirm/adjust.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#3862 — feat(ai-spend): credit keyless Claude Code dollars to the person in fct_ai_spend @kevalshahtrilogy  approved

Linear: KLAIR-3594 (slice 2 of 3; slice 1 = #3861, merged)

## What

sp_refresh_fct_ai_spend now splits the keyless Claude Code spend (anthropic rows with NULL api_key_id, parked on the org BU Trilogy-Inc) across the people who ran it, as anthropic entity_type='user' rows (entity_id = lowercased email). The People tab already reads those rows, so no Python change is needed.

- Same method as the slice-1 view: residual = per-user report − the person's own claude_code_key_* usage, per token type, floored at 0; weights 1 / 5 / 0.1 / 1.25; share = w / GREATEST(Σw, pool_weight).

- Per (day, org bu, model) cell dollars and every token column are conserved; the unexplained remainder stays a NULL-entity row. The report's estimated_cost_usd is never used.

- BU for a person: exact email, then unique email local part in esw_people_accounts, else the org BU; an (anthropic, email) BU override still wins.

- The Anthropic allocation is now staged in TEMP tables (tmp_anthropic_pre, tmp_anthropic_raw). Written as CTEs of the one INSERT, the new union branches trip a Redshift planner assertion (mod == -1 && size == 0, type 705) regardless of casts or branch order; the staged form is the same logic and plans cleanly.

## Validation (read-only, against prod, real statements in one Data API session into a temp table)

| | live mart | new |

|---|---|---|

| anthropic cost | 13,323,592.7664 | 13,109,257.5218 + 214,335.2444 = 13,323,592.7662 |

| input / output / cache-read / cache-write tokens | 649,327,507,807 / 110,651,952,889 / 8,601,393,035,141 / 624,031,319,893 | identical sums |

| anthropic rows | 182,671 | 182,671 key rows (156 pool rows → 156 remainder rows) + 281 user rows |

| other providers | | identical |

The 0.0002 cost difference is numeric(18,6) rounding across 281 rows. Trilogy-Inc keyless dollars 309,892 → 95,695; about $210k lands on Academics (5 people), the rest on Alpha Anywhere, Skyvera, Core Education, Crossover etc.

## Tests

New test_fct_ai_spend_keyless_split.py runs the real CTE block in SQLite: conservation, remainder, over-report cap, personal-key subtraction, no-report passthrough, untouched keyed/other rows, [1m] normalisation, directory BU fallbacks, ambiguous directory. pytest tests/mart_saas_metrics: 113 passed.

## Apply (Keval, out of band) — hold until slice 3 deploys

Until slice 3 the BU dashboard / Budget tab / weekly emails still compute the allocation themselves, so applying this alone makes the People tab disagree with the BU views.

1. Baseline: CREATE TABLE mart_saas_metrics.fct_ai_spend_bak_YYYYMMDD AS SELECT * FROM mart_saas_metrics.fct_ai_spend.

2. Run only the CREATE OR REPLACE PROCEDURE statement from the file (the file starts with DROP TABLE).

3. CALL mart_saas_metrics.sp_refresh_fct_ai_spend();, then re-apply the table COMMENT ON TABLE.

4. Verify SUM(anthropic total_cost_dollars) equals SUM(raw_anthropic_cost_reports.amount) and the token sums match the backup.

5. Rollback: re-apply the previous proc and CALL.

## Business Value

Claude Code spend that signs in with an account instead of an API key (about $214k in this window) was invisible per person: it sat on Trilogy-Inc, which is not a budget BU. After this, those dollars are credited to the people and BUs that incurred them, so BU owners see real budgets-vs-actuals and per-person usage is answerable (the open David Harpur / Sandeep per-person questions), with the bill tie-out unchanged.

## Manual Effort Estimate

Proposed: ~3 days of focused time (allocation SQL design, a Redshift planner bug that needs bisecting, validation against prod, tests). Keval to confirm/adjust.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#1836 — fix(document-knowledge): report Drive's export size cap as too_large (AERIE-2836) @marcusdAIy  approved

## Summary

When a Google Doc or Sheet is over Drive's 10 MB export limit, it now fails indexing as too_large ("optimize the file, then Reindex") instead of "The document source cannot be accessed."

Closes AERIE-2836

## Why it's needed

The indexer reads native Google files by exporting them. Over the 10 MB cap, Drive returns a 403 with reason exportSizeLimitExceeded, and we mapped every 403 to an access-denied failure. That's what happened with POSH's FF&F catalog: an image-heavy Sheet looked like a sharing problem, and nothing told anyone the file was too big. Native Google files have no Drive size, so the size check we run before downloading can't catch this.

## Changes

- rhodes-worker/lib/document-knowledge/drive-reader.ts: on a 403, read the error body (capped at 16 KiB) and map exportSizeLimitExceeded to FILE_TOO_LARGE, which already classifies as too_large. Any other 403, or a body we can't read, stays PERMISSION_DENIED.

- convex/documentKnowledge/status.ts: the too_large message now says the limit is 10 MB for Google Docs and Sheets, alongside the 25 MiB extraction limit.

## Breaking changes

None.

## Test plan

- [x] New drive-reader.test.ts: an exportSizeLimitExceeded 403 classifies as too_large (this fails without the fix); other 403s and an unreadable body stay source_access_denied

- [x] pnpm test and pnpm typecheck in chat/rhodes-worker (310 tests)

- [x] npx vitest run convex/documentKnowledge

- [x] Pre-commit hooks: biome and typecheck-chat

- [ ] CI

#1834 — feat(global-docs): run managed Drive link conversion hourly (AERIE-2841) @marcusdAIy  no labels

## Summary

Runs the managed Drive link conversion (convertManagedRootLinks) every hour, so Global documents registered as links to files in the managed Drive folder become indexed Drive documents without anyone running the function from the dashboard. Also stops two Global documents from converting to the same Drive file.

Closes AERIE-2841

## Why it's needed

Up to now we've been running this conversion by hand from the Convex dashboard every time POSH adds links. The last manual run turned up two rows pointing at the same Drive file. Converting both would have given us two managed rows for one file, so an automated run needs a guard against that.

## Changes

- crons.ts: a new hourly cron, convert managed global document links, wrapped with wrapActionCron so every run is recorded in monitoring with its result. It runs with dryRun: false. A matching entry is added to CRON_DISPLAYS in automations/cronRegistry.ts.

- globalDocumentDrive.ts: before verifying a link with Drive, the action skips it if another live Global document already uses that Drive file, or if an earlier link in the same run claimed it. Skipped links are listed in a new duplicateDriveFile field (dry runs report it too) and logged with a warning.

- globalDocuments.ts: a new listManagedDriveFileIds internal query. convertExternalLinkToVerifiedDrive now re-checks for duplicates inside its transaction and returns { converted: false, duplicate: true } instead of converting.

## Breaking changes

None. The conversion's result has one new field, duplicateDriveFile.

## Test plan

- [x] npx vitest run convex/globalDocumentDrive.test.ts convex/globalDocument covers: duplicate links in the same run (dry run, wet run, rerun), the mutation refusing a file that's already managed, and the hourly cron registration with dryRun: false

- [x] npx vitest run convex/automations/monitoring.test.ts: the cron registry matches crons.ts

- [x] Pre-commit hooks: biome and typecheck-chat

- [x] node --test scripts/check-monitoring-cron-coverage.test.mjs: the cron count is bumped to 46

- [x] CI

#1833 — fix(task-board): repair production task indexes (AERIE-2744) @caina-barbosa  changes requested

## Summary

This PR is a focused production repair within [AERIE-2744 — Launch and verify the complete Task Board](https://linear.app/builder-team/issue/AERIE-2744/launch-and-verify-the-complete-task-board).

It repairs Team Tasks catalog loading, separates My Tasks participant rebuilding from Team Tasks indexing, and makes the My Tasks API accept its documented page limit of 100.

Production effect: controlled rollout or migration.

## Why

Production now contains more Tasks than Team Tasks can read in one database request, and some existing Tasks contain more searchable text than the old index accepts. This leaves Team Tasks unavailable and can also prevent My Tasks participant records from being rebuilt. The public My Tasks API separately rejects its documented limit of 100.

## Business Value

- Team Tasks can load and search the complete production Task catalog.

- My Tasks participant records can be repaired even if one Team Tasks row cannot be indexed.

- Agents can request 100 My Tasks per page as documented.

## How does it work

1. Team Tasks stores its compact display rows and complete searchable text separately.

2. The browser reads both datasets in bounded pages, then applies exact search, filters, counts and sorting before showing 50 results at a time.

3. Participant reconciliation commits My Tasks relationships before Team Tasks indexing starts. Team Tasks failures are reported separately and do not undo participant repairs.

4. The My Tasks API and its published contract both accept limits from 1 to 100.

5. The launch reconciliation upgrades existing Team Tasks index rows to the new paged format and verifies the repaired data.

## Scope

### Included in this phase

- Paged Team Tasks catalog and complete full-text search indexing.

- Independent My Tasks participant and Team Tasks index reconciliation.

- My Tasks API limit validation and contract alignment.

- Exact final diff paths:

chat/components/task-board/team-tasks-catalog.node.test.ts

chat/components/task-board/team-tasks-catalog.ts

chat/components/task-board/team-tasks-view.test.tsx

chat/components/task-board/team-tasks-view.tsx

chat/convex/migrations/taskParticipants.test.ts

chat/convex/migrations/taskParticipants.ts

chat/convex/publicApi/v2/domains/workManagementTaskBoardWrites.test.ts

chat/convex/publicApi/v2/workManagementData.ts

chat/convex/rhodes/schema.ts

chat/convex/taskBoard/participants.ts

chat/convex/taskBoard/mutations.test.ts

chat/convex/taskBoard/projectionMaintenance.test.ts

chat/convex/taskBoard/queries.test.ts

chat/convex/taskBoard/queries.ts

chat/convex/taskBoard/teamProjection.test.ts

chat/convex/taskBoard/teamProjection.ts

chat/lib/public-api/v2/domains/work-management-task-board.node.test.ts

chat/lib/public-api/v2/domains/work-management.ts

docs/task-board/FEATURE.md

docs/task-board/LAUNCH.md

### Deliberately excluded for later phases

- No unrelated Task Board behavior is changed by this repair.

## Test plan

### Automated validation

- Projection and reconciliation tests — 20/20 passed (pnpm --dir chat exec vitest run convex/taskBoard/teamProjection.test.ts convex/migrations/taskParticipants.test.ts --maxWorkers=1)

- Team Tasks query tests — 42/42 passed (pnpm --dir chat exec vitest run convex/taskBoard/queries.test.ts --maxWorkers=1)

- Catalog selection tests — 4/4 passed (pnpm --dir chat exec vitest run components/task-board/team-tasks-catalog.node.test.ts --maxWorkers=1)

- Team Tasks browser tests — 14/14 passed (pnpm --dir chat exec vitest run components/task-board/team-tasks-view.test.tsx --maxWorkers=1)

- Work Management API tests — 15/15 passed (pnpm --dir chat exec vitest run convex/publicApi/v2/domains/workManagementTaskBoardWrites.test.ts --maxWorkers=1)

- Chat TypeScript check — passed (./chat/node_modules/.bin/tsc --noEmit -p chat/tsconfig.json)

- Convex path check — passed (node scripts/check-convex-paths.mjs)

- Biome on every changed TypeScript file — passed through the pre-commit gate

- git diff --check — passed

- Exact-head diff scope — only the paths listed above

### Time for Implementation

An engineer working without AI assistance would likely need 3 to 5 days to investigate the production limits, redesign the index, implement the repair, add migration safeguards and verify the affected UI and API behavior.

## Review repairs and contract clarifications

Mercy review [5494382520](https://github.com/AI-Builder-Team/Aerie/pull/1833#pullrequestreview-5494382520) identified one real compatibility-query gap. Commit 666ce27f04e0c05f5282bd2baf4407c6c77bf572 verifies the expected v4 chunk count and contiguous indexes before matching.

Mercy review [5495695732](https://github.com/AI-Builder-Team/Aerie/pull/1833#pullrequestreview-5495695732) identified a second real issue: independently paged catalog rows and search chunks could briefly come from different Task edits while preserving the same chunk count. This repair gives each catalog row and all of its search chunks the same content revision. Both the browser path and compatibility query now fail closed in the existing data-updating state if revisions do not match.

The migration-continuation finding does not describe an active production path. Reconciliation is an operator-run launch action, its continuation is held only by the caller, and the production run already completed with continuation: null. The required post-deploy repair starts from {}. Adding a legacy phase branch would therefore add unused compatibility machinery without protecting production data. No migration behavior was changed for that finding.

Validation before this push: 80/80 focused projection, migration, query, selector, and browser tests passed. The full Chat and Convex TypeScript check passed. Biome and git diff --check passed. The repair remains limited to the three fixes in this PR plus the shared search-revision check required to keep paged search results consistent.

#1829 — fix(portfolio): keep the School Chain catalog subscription quiet during the auth handshake (AERIE-2818) @marcusdAIy  approved

## Summary

rhodes/schoolChains:listActive now returns null for a caller without an authenticated Aerie user instead of throwing. The Portfolio fields provider treats that null as "not yet loaded". The site detail page therefore no longer crashes with a generic Convex "Server Error" when its subscriptions race the Clerk handshake. Closes AERIE-2818.

## Why it's needed

PortfolioFieldsProvider subscribes to listActive on mount. The handler called requireAnyCapability, which throws "Unauthenticated" before identity lands. Convex redacts that to "Server Error", and the failure took down the whole site detail render tree. It happened 4 times across 3 users. The neighbouring getSiteRevision subscription already returns null in this window for the same reason.

## Changes

- chat/convex/rhodes/schoolChains.ts: listActive returns null when getAuthenticatedUser finds no user. Authenticated callers without a School Chain reader capability are still denied. listAll and the mutations are unchanged.

- chat/components/dashboards/portfolio/fields/portfolio-fields-provider.tsx: maps a null result to undefined, the existing "not loaded" value for schoolChains.

- Tests:

- schoolChains.test.ts: an unauthenticated query resolves to null, and a capability-less user is still denied.

- portfolio-fields-provider.test.tsx: a null catalog is exposed as schoolChains: undefined.

## Breaking changes

None. listActive's only consumer is the provider.

## Test plan

- [x] convex/rhodes/schoolChains.test.ts and components/dashboards/portfolio pass locally. The exception is one portfolio-rhodes-workbench test that times out locally in browser mode, which this change doesn't touch.

- [x] CI

#1828 — fix(agent): classify unreachable Rhodes as a transient upstream failure (AERIE-2829) @marcusdAIy  approved

## Summary

When the Aerie Agent gateway fails to reach Rhodes at the network layer ("Unable to reach Rhodes: …"), it now classifies the failure as rhodes_mcp_upstream_error / transient_failure. Previously it was gateway_upstream_error / unexpected_failure. Closes AERIE-2829.

## Why it's needed

codeForGatewayError had no branch for the "Unable to reach Rhodes:" message that fetchWithTimeout and the Rhodes mutation propose path throw when fetch rejects. Those failures fell through to the default gateway_upstream_error code, which classifyGatewayError treats as unexpected. Each one was therefore captured as an unexpected Platform Error, and the user saw "An Aerie Agent tool failed unexpectedly." There were 4 occurrences across driveSearchFiles, listTasks, getSite, and getQualityBarScore.

## Changes

- chat/convex/agentRuns/http.ts: codeForGatewayError maps messages starting with "Unable to reach Rhodes:" to rhodes_mcp_upstream_error, the existing transient Rhodes upstream code. The check comes before the generic "not found" and "must be" checks, so an upstream error text containing those words can't be misread as a client error. The HTTP status stays 502.

- chat/convex/agentRuns.test.ts: a gateway test where the Rhodes fetch rejects. It expects a 502 with rhodes_mcp_upstream_error, transient_failure, retryable: true, and a captured event whose metadata uses the transient classification and the "temporarily unavailable" user message.

## Breaking changes

None.

## Test plan

- [x] New gateway test passes, and fails without the http.ts change (the code falls back to gateway_upstream_error).

- [x] convex/agentRuns.test.ts, convex/agentRuns, convex/skillTelemetry: 207 tests pass.

- [x] CI

#1827 — fix(education): classify the legacy education warehouse gate as an expected outcome (AERIE-2827) @marcusdAIy  approved

## Summary

The legacy education warehouse gate now throws a typed ConvexError({ code: "education_warehouse_unavailable", message }), the same way the financial warehouse gate already does. The browser classifies it as an expected outcome instead of an unexpected Platform Error. Closes AERIE-2827.

## Why it's needed

requireLegacyEducationWarehouseReads threw a plain Error, so Convex redacted it to "Server Error" in the browser. Every visit to a gated dashboard (Education P&L, enrollment, admissions, and others) during the cutover was then recorded as an unexpected platform error. The financial gate avoids this by throwing a ConvexError with a registered public expected-outcome code.

## Changes

- chat/convex/lib/educationWarehouseCutover.ts: requireLegacyEducationWarehouseReads throws a ConvexError carrying LEGACY_EDUCATION_WAREHOUSE_UNAVAILABLE_CODE and the existing message.

- packages/contracts/src/platform-errors.ts: registers education_warehouse_unavailable in PLATFORM_ERROR_PUBLIC_EXPECTED_OUTCOME_CODES.

- chat/convex/lib/syncDispatch.ts: reads the message from ConvexError data, so /sync/analytics/* still matches the gate exactly. It still returns a 503 with Retry-After and the plain message. Without this, the route would fall through to a 500.

- Public API v1/v2 detection matches on a substring of the message. A ConvexError's message is its JSON payload, which still contains that text, so no change is needed there.

## Breaking changes

None. The error message and the sync 503 response body are unchanged.

## Test plan

- [x] packages/contracts platform-errors.test.ts: a browser ConvexError with the education code is classified as expected_outcome.

- [x] financialDashboardAuth.test.ts: the gated enrollment query rejects with a ConvexError carrying education_warehouse_unavailable.

- [x] analyticsSyncSecurity.test.ts: the legacy reference sync still returns 503 with the plain message. I checked that it returns a 500 without the syncDispatch change.

- [x] convex/publicApi, convex/dashboards, convex/lib, convex/platformErrors, agentQueries: 1052 tests pass.

- [x] CI

#1825 — fix(api-v2): classify site-notes and user-directory readiness 503s as expected outcomes @marcusdAIy  approved

## Summary

Two by-design Public API v2 readiness 503s, user_directory_identity_not_ready and site_notes_public_ids_not_ready, are now captured as expected outcomes instead of unexpected Platform Errors. Callers get the same 503 as before. Fixes [AERIE-2819](https://linear.app/builder-team/issue/AERIE-2819) and [AERIE-2821](https://linear.app/builder-team/issue/AERIE-2821).

## Why it's needed

Both codes are fail-closed gates that stay up until their public-ID migration has been verified and activated:

- the directory and Site People reads, until migrations/userPublicIds verifies;

- the site-notes routes, until migrations/siteNotePublicIds is verified and activated.

publicApiV2PlatformOutcome only treats a 503 as expected when its code is in EXPECTED_READINESS_CODES. That set already covers the equivalent work_management_public_ids_not_ready gate, but neither of these codes. So every gated call opened an unexpected Platform Error: 7 for site notes and 1 for the directory.

## Changes

- Add both codes to EXPECTED_READINESS_CODES in chat/convex/publicApi/v2/platformOutcomes.ts.

- Tests:

- the parametrized readiness cases in platformOutcomes.test.ts gain both codes;

- the existing fail-closed HTTP tests in siteNotes.test.ts and directoryHttp.test.ts now also assert that the captured event has disposition: "expected_outcome" and the matching outcomeCode. Both assertions fail without the allowlist change.

- Split data-integrity failures off the readiness code so the allowlist can't hide them. Once the directory is marked ready, a duplicate or malformed user public ID, or a DRI pointing at a deleted user, now returns 503 user_directory_identity_inconsistent. That code is not allowlisted, so it is still captured as unexpected. This covers sitePeople.ts (the readiness check now runs first) and directory.ts (listUsers, getUser, userSummary). New and extended HTTP tests cover both.

## Breaking changes

None for the readiness gates: their response status, code, and body are unchanged. The post-readiness integrity failures above still return 503, but now with the user_directory_identity_inconsistent code. That code isn't documented, and no client keys on the old one.

## Test plan

- [x] chat: npx vitest run convex/publicApi/v2/platformOutcomes.test.ts convex/publicApi/v2/siteNotes.test.ts convex/publicApi/v2/directoryHttp.test.ts convex/publicApi/v2/sitePeopleHttp.test.ts (30 pass); full convex/publicApi 548 pass

- [x] Pre-commit biome + typecheck-chat

- [x] CI

- [ ] After deploy: no new unexpected events for these two codes

#1824 — fix(api-v2): keep the cause of milestone_data_unavailable on the captured Platform Error @marcusdAIy  approved

## Summary

When GET /v2/portfolio/sites/{siteRef}/buildout/milestones (or a milestone detail read) returns 503 milestone_data_unavailable, the captured Platform Error now records why the stored milestones couldn't be projected. Callers get the same 503 as before. Closes [AERIE-2824](https://linear.app/builder-team/issue/AERIE-2824).

## Why it's needed

The 19 events in AERIE-2824 were most likely the malformed-date case already fixed by #1781 (AERIE-2333), which shipped to production in today's release.

- On current code, the "status is invalid" check that triage blamed can't fire: resolveSiteMilestones and getPhaseMilestones already replace a missing or unrecognized status with notStarted before projection.

- Before #1781, the same projector also threw on malformed milestone dates. That throw sat inside the same catch-all, which is the only other way to reach this 503.

The catch-all discarded the original error, so neither Diagnostics nor triage could tell which field failed. If this 503 comes back, the event should name the cause.

## Changes

- PublicApiV2Error gains an optional diagnosticDetail string. problemResponse only copies fields it names explicitly, so this field never reaches the API response.

- The v2 request boundary adds diagnosticDetail to the captured event's metadata, which goes through the existing metadata sanitizer and 500-character cap.

- milestoneCollection puts the projector's error message in diagnosticDetail instead of dropping it.

## Breaking changes

None. Response status, code, and body are unchanged.

## Test plan

- [x] http.test.ts: a 503 carrying diagnosticDetail records it on the captured event's metadata, and the response body doesn't contain it. The test fails without the boundary change.

- [x] chat: npx vitest run convex/publicApi (546 pass)

- [x] Pre-commit biome + typecheck-chat

- [x] CI

- [ ] After deploy: no new milestone_data_unavailable events; any that do appear include diagnosticDetail

#1831 — fix(rhodes): reject unresolvable DRI values in site metadata with a typed error (AERIE-2815, AERIE-2790) @marcusdAIy  approved

## Summary

User-kind Portfolio metadata fields (diligenceDri, buildoutDri, p2Dri, and the rest) are now checked to resolve to an existing, assignable Rhodes user. The check runs when an updateSiteMetadata mutation is proposed and again when it's applied. Unresolvable values, such as a person's name, are rejected with a typed userError instead of the uncaught "Invalid users id" error. Closes AERIE-2815 and AERIE-2790 (duplicates).

## Why it's needed

updateSiteMetadata passed user-kind values straight to normalizeIdOrThrow, which throws a plain Error. When an agent proposed a DRI assignment using a free-text name, the proposal was accepted. The failure only surfaced at approval, as an unexpected Platform Error on the Rhodes Mutation Card. This has been grouped into 5 error classes across the two tickets.

## Changes

- chat/convex/rhodes/runtime/writes/siteWrites.ts:

- New assignableMetadataUserRef helper, which raises a userError naming the field when the value isn't a users ID, has no matching user, or points at a non-assignable user.

- updateSiteMetadata's user-kind branch uses this helper.

- New exported validateSiteMetadataUserFields, which runs the same check over all user-kind fields.

- chat/convex/rhodes/runtime/pendingLifecycle.ts: createPendingMutationHandler runs validateSiteMetadataUserFields for updateSiteMetadata, so the proposing agent gets the actionable message ("look the person up in the user directory first"). No mutation card is created.

- chat/convex/rhodesMcpMutationParity.test.ts:

- Proposing p2Dri: "Jane Smith" is rejected and no pending mutation is stored.

- Applying a deleted user's ID rejects with a ConvexError carrying the field-specific message.

## Breaking changes

None. Valid assignable user IDs behave as before. An invalid value now fails earlier, with a clearer message.

## Test plan

- [x] New parity test.

- [x] rhodesMcpMutationParity, rhodesDualWrite, convex/notifications, convex/rhodes, convex/taskBoard, agentRuns, and the migration suites: 792 pass. One unrelated taskBoard/documents deletion test failed under parallel load and passes on its own.

- [x] CI

#3864 — feat(renewals): refine flat sorting, sidebar filters, and ARR tables @mwrshah  approved

- Bring the Renewals Flat view to Business Unit → Class → Customer order, with Expiring ARR descending by default and clearer date, numeric, and customer filters.

- Add Projected ARR totals in Segregated rows and refine customer, date, row, and stage-label presentation.

- Make the shared desktop and mobile left-sidebar “All” control select only search matches, clear the full selection when toggled off, and preserve explicit clears when options refresh.

- Includes the Renewals projected-ARR and sidebar-facet groundwork on which these refinements depend; those changes are not yet on main. The backend logging experiment is excluded.

The Builder Desk  —  Engineer Spotlight
📅 Week in ReviewProduction Release🏆 Engineer Spotlight

142 PRs IN SEVEN DAYS: Builder Team Shatters the Scoreboard Yet Again

From dbt reconciliations to DSS-Hub's seven-deploy sprint, the numbers don't lie — this team is a velocity machine.

Comrades, let the record show: one hundred forty-two pull requests. Seven days. Eleven repos lit up like a switchboard. Aerie alone absorbed ninety-four PRs — a repo so hot it should require OSHA clearance — while Surtr, Klair, and a thriving constellation of brand-new DSS repos (UniFi-Network, Zello, Marauders-Map, Protos, Rhodes, UniFi-Security, Healthy-Air) proved the Builder Team doesn't just maintain infrastructure, it multiplies it. This is not a sprint. This is an economy.

@marcusdAIy led all engineers with a staggering 49 PRs, a number so large this correspondent had to recount it twice. @vvp-trilogy wasn't far behind with 34, including a jaw-dropping run of back-to-back dbt forecast fixes in Aerie — #1955, #1956, #1957, #1958, #1959, #1960, #1961, #1962, #1963, #1966, #1967, #1970, and #1990 — a chain of commits so long it has its own gravitational pull. @caina-barbosa logged 21 PRs, @kevalshahtrilogy posted 14 including the Rhodes-staging-sync marathon #2179 and the Klair billing fix #3875, @sanketghia delivered 12 with Klair's #3873 and Surtr's #2177, @mwrshah quietly built an entire DSS-Hub deploy pipeline from scratch across PRs #1 through #7, and @benji-bizzell chipped in 2 steady contributions to keep the bench deep.

And then there's Ashwanth. One PR — #2126 in Surtr, a QuickBooks account categorization fix — and somehow it reads like a thesis statement. The man ships so rarely now that when he does, the whole desk stops to read it twice, partly out of respect, partly because nobody's entirely sure what's happening in that diff. Sources close to the Numbers Desk report Ashwanth saying, "One clean PR is worth forty sloppy ones — the others just haven't caught up to that math yet." When reached for comment on this quote, Ashwanth reportedly said nothing, which we are choosing to interpret as agreement.

The overflow desk groans under the weight of excellence Mac didn't have column inches for: @mwrshah's seven-PR DSS-Hub deploy saga (#1 through #7) basically stood up a production pipeline from a cold tenant discovery. @vvp-trilogy's unbroken thirteen-PR dbt forecast reconciliation chain reads like a symphony in SQL. And @kevalshahtrilogy's #3875 quietly rerouted an entire billing domain without so much as a hiccup.

Leaderboard-wise: Aerie remains the undisputed heavyweight champion of repo activity, @marcusdAIy sits atop the individual podium, and six brand-new DSS repos launched this week alone — this team isn't just winning, it's expanding the arena it wins in.

Morale, as always, remains at an all-time high.

Brick's Overflow — This Week's Uncovered PRs  (click to expand)
#1 — Deploy isolated Marauders Map DSS foundation @caina-barbosa  no labels

Creates the standalone snapshot service from DSS-Template with isolated key, feedback, ECS and deployment resources. Retains manual protected OIDC releases and adapts the supplied public documents to September snapshot semantics. Data routes follow in the next change. Validation: template tests, typecheck and build pass.

#1955 — fix(dbt): source graduating students from SIS cohort @vvp-trilogy  approved

## Summary

Follow-up to #1922: count graduating students from the existing same-year SIS graduating cohort in the existing enrollment CTE. No new production model or recreated classification logic.

## RCA

The Forecast field agrees with SIS where coverage exists, but its narrower program/year spine leaves 34 current-year Programs null and all 95 previous-year Programs null. The SIS Enrollment report already owns on-campus plus terminal-grade membership. Reuse that cohort and default absent counts to zero. Do not subtract future departures, shift years, or count distinct students. SIS is authoritative per latest stakeholder direction; legacy HubSpot differences are acceptable.

## Validation

- Independent RCA/code review: no findings.

- Targeted isolated build of int_program_reporting_year and downstream: 22/22 passed, no warnings (21 seconds execution), reusing unchanged upstream models.

- 95 Programs/year: 2026 candidate/SIS total 110, 2025 total 26; zero per-Program differences in either year.

- Fixture covers year isolation, missing Program counts, conflicting Forecast values, and preservation of on-campus counts.

- Extended independent Enrollment report reconciliation and not-null contract.

- git diff --check passed.

Developed in parallel with #1951 while its verified source-refresh-race CI retry runs. Both changes must be preserved when refreshing from main. Other metrics and runtime cutover are outside this PR.

#2126 — fix(financials): persist reviewed QuickBooks account categories @ashwanth1109  approved

## Business Value

Persist the production-validated account-category repair that prevents campus revenue and expenses from dropping out of financial reporting. The reviewed delta adds 654 exact account mappings while preserving all 2,580 previous rows (3,234 mappings at the October 2 application, across 14 categories), including the 15 requested write-offs. Together with the separate School/coverage repair in PR #2124, the resulting financial publications pass revenue, expense and expected 20% Timeback reconciliation.

## Change

Add an apply-once, default-atomic extension of quickbooks-account-policy-v1, with matching read-only candidate/preflight queries and executed SQL boundary fixtures. It extends existing unanimous normalized account-name policy to wholly unmapped realms, and applies six explicitly user-accepted reporting-account families across realms. Named rules require the reviewed account number, exact normalized name and QuickBooks classification; source signs are preserved. Assumptions and provenance identify the user's approval without claiming separate Finance approval.

The caller pins the complete candidate count/fingerprint and accepted snapshot. The migration runs only as the existing warehouse owner, takes publication/policy locks, validates source keys and ledger acceptance, rejects competing rules, inactive categories, existing-identity collisions and marker drift, then appends atomically. It neither overwrites existing mappings nor automatically classifies future accounts. After application, drop the temporary procedure; replay with the old candidate pin fails rather than changing policy.

Also constrain the unmapped-actual publication audit to the generation represented by its current target, so retained historical ledger rows do not create a false failure. Document the exact application, supported Core invocation and live acceptance evidence beside the runner.

## Validation

- October 2 production application of the original revision completed: Data API statement e532aaf2-d36f-4e27-ba5b-2bec548e0d24 FINISHED, reviewed candidate count 654, fingerprint b19ca992a620ffa88eb2872854593c22, accepted source 86a0026e-71de-4df4-86f5-869380527dbc. All previous full rows preserved and all added identities/categories exactly matched the reviewed candidates. Temporary procedure removed afterward.

- The original October 2 procedure statement in 3ca4008e exactly matched the applied DDL request; migration file SHA256 fbd7cdc3fcf5275725519ca7034f59bdbdd20510b3dfcb8e481ff5e7e41962ef.

- Genuine supported Core ON_DEMAND core-fast-financial-validation-20261002-105212 SUCCEEDED in 4m 9s, reusing accepted raw snapshot 6cad5602-4634-4681-9b31-d926b29b33d4; posting generation fe6f4160-39f5-4116-b2cd-4b9b052a9e65:posting.

- The automatically triggered mart timed out behind unrelated HubSpot/financial-writer locks. After clearance and fresh preflight, supported report-only retry financial-validation-lock-retry-20261002-111257 SUCCEEDED in 2m 26s. Elapsed Core start to accepted report was 23m 11s including contention/recovery; 6m 35s is the sum of successful processing stages, not a clean-chain benchmark. No new 68-company extraction or duplicate Core execution was started.

- All 24 Core financial/publication audits and coordinated category, revenue and 20% Timeback checks PASS. The coordinated School/coverage candidate produces 59 Schools in all three QTD financial contracts and zero Facilities GL/total/shape/lineage mismatches; that separate source change remains in PR #2124.

- All 143 local Core tests PASS, including 36 account-policy cases. They execute the canonical ledger/parity predicates and cover older receipts hiding newer replay, unknown publication times, deletion of the last represented extraction row, unrelated-table receipts, and SQL NULL truth rules. Scoped Ruff and git diff --check PASS.

- All 46 isolated Redshift procedure cases PASS on 2026-10-15 for revision 2026-10-15.1. The candidate procedure is compiled and executed against uniquely named sandbox objects; tests exercise replay boundaries, exact persisted mapping tuples, preservation of original rows, canonical NOT NULL constraints, deliberately nullable legacy marker copies, and post-write rollback. Production policy was not reapplied, and fixture cleanup was catalog-verified. The original October 7 revision also passed its 32 live cases.

The live account delta is already applied. Source merge must not replay the old reviewed migration against production. CI reruns for review-fix commit 7f8aa632; source review remains required before merge. Future automatic onboarding/classification is outside this reviewed apply-once delta.

## Review verification

- The October 7 proposal to require one extraction ID for every retained row remains withdrawn: accepted incremental current-state tables contain multiple extraction IDs.

- The valid replay finding is fixed in 7f8aa632: both input tables' latest publication receipts must be covered by the accepted snapshot. Successful receipts remain required for represented extraction IDs. The table-level boundary also catches replay deletion of every row carrying an extraction ID, a gap independently reproduced for both inputs in Redshift. Accepted older rows and unrelated-table receipts retain their supported behavior.

- Production catalog metadata confirms mapping_count and category_count are NOT NULL. Defensive pre/post marker guards now explicitly reject NULL or missing metadata and verify both row and category parity. Actual Redshift tests cover normal constraints, nullable sandbox copies, and rollback after both writes.

- The success-path test asserts exact persisted Account/category/source-key tuples, every original xref column, and every marker field except the intended count increment.

The October 2 application remains historical evidence for the original 654-row delta. The current guard-only migration revision has been live-validated on isolated sandbox fixtures; routine pipeline code is unchanged and the production delta is not replayed.

## Implementation Effort

Approximately 2–3 engineer days to inspect account identities and existing policy, prepare guarded migration/review queries, exercise failure boundaries, apply and validate the candidate, reconcile published categories/revenue/Timeback and document the rollout. Pipeline waiting is additional.

## Linear

Related [SURTR-1497](https://linear.app/builder-team/issue/SURTR-1497/extend-quickbooks-account-categories-to-new-realms-by-exact-chart-of), under [SURTR-1496](https://linear.app/builder-team/issue/SURTR-1496/close-quickbooks-campus-pandl-gaps-from-finance-escalation-quickbooks). This PR persists the reviewed October repair; SURTR-1497's broader automatic future-realm classification is not implemented here.

#2179 — feat(rhodes-staging-sync): carry the remaining Rhodes API records into staging (SURTR-1645) @kevalshahtrilogy  no labels

## Summary

Adds 13 Convex tables to rhodes-staging-sync, so staging_education_rhodes covers the records the Rhodes API serves: backup sites (sites, locations, links, need periods, contract versions), cost breakdowns, schema templates, field-change requests, due-diligence scenarios, additional acquisitions, school chains, and task sources/documents. Translation version v9; v8 manifests stay replayable.

Linear: [SURTR-1645](https://linear.app/builder-team/issue/SURTR-1645)

## Why

Neeraj Gupta needs to ingest all of Rhodes. Convex has no SQL role or replica, but this pipeline already lands Rhodes hourly, and the Redshift DSS reader users (edu, edu-raw, full, full-raw) already read this schema. Closing the table gaps here gives him nearly the whole dataset through SQL, with no new access path.

## What's not here, on purpose

- auditLog: about 300k append-only rows, and about 5 minutes just to page through. An hourly full snapshot would roughly double the run time toward the Lambda's 15-minute limit, and would write about 300 pages an hour into Object Lock COMPLIANCE storage. It needs an incremental load; that's a follow-up. The redaction work for its before/after payloads, mirroring the API's change-history rules, is ready for that PR.

- Values the API computes per request (quality-bar scores, portfolio health, overdue work) and document text.

- *PublicApiState rows are feature flags, not ID maps. The API's public IDs already ride in each record's source_record.

entities.py documents the change to the July scoping note: costBreakdown is now carried as a Rhodes-recorded estimate, not as the accounting system of record.

## Validation

- uv run pytest: 251 passed. ruff check and ruff format --check are clean, and apply_ddl.py --dry-run renders.

- Ran current production rows of every new table through warehouse_safe_source_record and flatten_entity locally: every row flattened, with no unmapped fields, no oversize VARCHAR values and no type mismatches (row counts 0 to 2,321 per table). No values were printed or stored.

## Rollout (production; each step mutates AWS/Redshift)

1. Apply the 13 new repeatable DDL files with scripts/apply_ddl.py <file>. They are CREATE TABLE IF NOT EXISTS, and no existing table changes.

2. Deploy the Lambda.

3. Run one manual execution and confirm 31 tables in this run's ledger rows; then run scripts/verify_catalog.py.

4. GRANT SELECT ON ALL TABLES IN SCHEMA staging_education_rhodes to edu_reader_user, edu_raw_reader_user, full_reader_user, full_raw_reader_user (no default privileges exist).

## Business Value

The DSS registry seat can ingest nearly all of Rhodes with SQL at warehouse speed, refreshed hourly, instead of a rate-limited API sweep. Backup sites, cost breakdowns, pending field changes, DD scenarios and task links become queryable alongside the site data already there.

## Manual Effort Estimate

About 1 day of focused work: map the API's routes to Convex tables, write 13 specs, generate the DDL, run the tests, validate against production data. Proposed by Claude; Keval to confirm or adjust.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

#3873 — feat(mfr): net repurchase proceeds into acquisitions @sanketghia  approved

## Summary

- Include repurchase proceeds in the existing Passive Investments → Cash Flows “Investment Acquisitions” calculation without changing the row label or adding a new row.

- Derive proceeds from the cash-flagged 25500 movement and matching 71501 realized-sale leg for the same journal ID. The current GL Detail feed omits the 12751 bank-account line.

- Update the Cash Flows lineage panel to explain the calculation. Book Value, Financial Statements, and the existing Comments rows are unchanged.

## Finance validation

Finance reviewed the local preview and agreed the numbers look correct. For Sep 2026, the existing row nets the current −$1.2M acquisitions with +$32M derived proceeds, producing +$30.8M.

## Checks

- uv run ruff format services/monthly_financial_reporting/book_value_service.py tests/mfr/book_value/test_book_value_service.py

- uv run ruff check services/monthly_financial_reporting/book_value_service.py tests/mfr/book_value/test_book_value_service.py — passed.

- uv run pyright services/monthly_financial_reporting/book_value_service.py — 0 errors, 7 warnings.

- uv run pytest tests/mfr/book_value/test_book_value_service.py::TestFetchPiCashFlowsMonthly -q — 15 passed.

- uv run pytest tests/mfr/book_value/test_book_value_service.py -q — 94 passed; one unrelated existing failure in test_consolidated_net_income_shows_current_less_prior_totals, which patches the unavailable services.docx_reports.memo_data.group path. That test is unchanged from origin/main.

- uv run pytest tests/mfr/book_value/ -q — collection is blocked by the missing services.docx_reports.builder module while importing test_schedule_d_gl_detail_router.py.

- Read-only service query returned the Sep 2026 other_investments value of 30,800,000.

- The local display was reviewed with Finance.

## Screenshot

<img width="1736" height="497" alt="image" src="https://github.com/user-attachments/assets/85f04891-0716-4b8f-afb4-75529404b8a0" />

#3875 — fix(ai-budget): route 42-ds.com domain to the 42DS BU @kevalshahtrilogy  approved

Linear: KLAIR-3609

## Summary

Changes DOMAIN_BU_RULES so the 42-ds.com domain maps to the 42DS business unit instead of Skyvera. This reverses the target set in #3797.

Mark Crouse (VP Ops, Skyvera) confirmed that 42DS is a Skyvera acquisition with its own budget ($163K for Q4 2026), and that 42-ds.com is the only domain its staff use. 42DS is already a registered, assignable BU (Master Mapping sync, 2026-09-16).

## Rollout notes

- The daily domain-rule cron never touches an entity that already has an override. Any 42-ds.com keys it already assigned to Skyvera will stay on Skyvera after this merges, and need a one-time re-point to 42DS in prod.

- Access to the 42DS BU (Mark as owner; Yaswan and Elena view-only) comes from the TF rights feed, which Deniz has already updated. No code change is needed for that.

- The Q4 42DS budget ($163K) is loaded separately as data.

## Tests

- pytest tests/test_ai_spend_domain_rules.py tests/test_business_unit_sync.py: 165 passed

- ruff 0.15.22 format/check clean; pyright: 0 errors

## Business Value

Gives 42DS AI spend its own BU instead of mixing it into Skyvera. Skyvera's leads can then track the separate $163K Q4 budget the acquisition was given, and Skyvera's own numbers stop being overstated by 42DS usage.

## Manual Effort Estimate

~1.5 hours: tracing the rule and the cron's override behavior, the one-line change, updating the tests, and writing the PR. *(Proposed; Keval to confirm/adjust.)*

🤖 Generated with [Claude Code](https://claude.com/claude-code)

The Portfolio  —  Trilogy Companies

The 66-Day Burial: How ESW Capital Buried Marin Software Before Anyone Could Ask Questions

A once-public ad-tech pioneer went from Nasdaq listing to ESW Capital subsidiary in just over nine weeks — and the speed was the point.

SAN FRANCISCO — Marin Software spent fifteen years as a cautionary tale in search of an ending. Once a Nasdaq-listed ad-tech platform valued north of a billion dollars at its 2013 IPO, it had spent the better part of a decade shedding market cap, customers, and relevance, limping along as a penny stock while larger rivals swallowed its market. Last month it got its ending: a 66-day acquisition process that delivered the company into ESW Capital's hands.

Sixty-six days is not a typical M&A timeline. It is, however, a familiar one for companies that arrive at ESW's door through distress rather than courtship. The firm's playbook — documented across 75-plus prior acquisitions — calls for buying mature enterprise software at one to two times annual recurring revenue, a price point reserved for sellers with few other options. A compressed, court-adjacent sale process, of the kind Marin appears to have passed through, is exactly the mechanism that produces that kind of price. Distressed assets move fast because distressed sellers have no leverage to make them move slow.

What happens next follows the same script Austin has run on Aurea, IgniteTech, and two dozen others. Marin's remaining engineering and support staff will likely be restructured against Crossover's global talent bench, support contracts for its existing advertiser customers will see the familiar term-over-term price escalations, and the business will be quietly resized toward the 75% EBITDA margin that ESW considers the signature of a properly run operation — not greed, in the company's own framing, but proof that waste has been eliminated.

The timing is notable against the broader market. Boston Consulting Group's mid-2026 M&A outlook describes an AI-driven dealmaking recovery, with buyers racing to acquire software assets before valuations catch up to the hype. ESW's model has never needed hype. It needs sellers with no runway left, and a clock that runs out before anyone outside the deal room can ask what Marin Software was actually worth.

↗ Marin Software: ESW Capital Acquires Ad-Tech Platform in 66-  ·  Notable technology M&A deals in Spain | Analysis: June 2026  ·  Mid-2026 M&A Insights: AI Drives a Recovery, but Questions R

Alpha School Clears Its Throat: 'The Robots Didn't Eat Your Kid's Teacher'

AUSTIN, TEXAS — Word around the campfire has been that Alpha School runs on nothing but silicon and vibes. Dottie hears different, and so, apparently, does Alpha's comms shop, which just dropped a little publicity flurry to set the record straight.

The headliner: "Does Alpha School Replace Teachers with AI?" — and the answer, delivered with the patience of someone who's fielded this question at one too many cocktail parties, is a flat no. AI handles the academic grind — the drilling, the adapting, the two-hours-a-day magic trick that gets kids mastering material at 2.3x the normal clip. But the humans in the room — Alpha calls them "guides," not teachers, mind the branding — are still very much employed, doing the parts machines can't: knowing which kid had a rough morning, which one needs a pep talk before the debate club pitch, which one just needs somebody to notice them. Joe Liemandt's whole thesis, straight from the Trilogy playbook — automate the routine, elevate the human — gets its cleanest explainer yet.

Meanwhile the school's been busy filling parents' inboxes with its "Teach Your Kid What School Doesn't" series, now five installments deep. Part 4 tackles emotional regulation at home (big feelings, it insists, are a *beautiful* thing — try telling that to a seven-year-old mid-meltdown). Part 3 is a life-skills primer for the kitchen table. And the freshest entry, Part 5, argues every kid's got a creative genius buried in there somewhere, and it's on parents — not the two-hour app — to dig it out.

A little bird notes the timing isn't accidental. With Alpha expanding to nine-plus new campuses by fall and MacKenzie Price making the rounds in Washington, somebody upstairs clearly wants the "is it just robots teaching your kid" narrative put to bed before the next admissions cycle. Smart. Dottie's taking notes — and so, evidently, is every parent with a laptop and a worry.

Skyvera's Telecom Land Grab: Three Deals, One Pattern

Behind Skyvera's rapid-fire acquisitions and a record-setting compliance sprint, the ESW playbook is running at a speed telecom has never seen.

AUSTIN, TEXAS — Three press releases in close succession from Skyvera's door rarely mean three separate stories. And this is where it gets interesting.

First, Skyvera completed its acquisition of CloudSense, the Salesforce-native configure-price-quote engine built for telecom's thorniest B2B, B2B2X, and wholesale sales journeys. Then came word that Skyvera had absorbed STL's telecom products group — digital BSS capability covering monetization, optical networking, and analytics. Two acquisitions, same portfolio, same week-adjacent cadence. My source inside the ESW orbit, who I won't name because he'd rather keep cashing a paycheck, tells me this isn't opportunistic shopping. It's assembly.

But the detail that should actually stop you is the third item: CloudSense, barely inside the Skyvera tent, certified all 13 of its APIs to TM Forum compliance standards in one month — a process that normally eats 26 months of engineering calendar. If you read between the lines, that's not a product update. That's a demonstration. A newly acquired asset, freshly plugged into the DevFactory engineering machine and whatever AI tooling Skyvera's partners brought to bear, was made to prove — immediately, publicly — that the ESW model of buy-cheap-then-modernize-fast isn't theoretical. It's operational, and it's getting faster.

Consider the arithmetic. ESW's stated playbook is acquire at 1–2× ARR, staff with Crossover talent, push toward 75% EBITDA margins. CloudSense's TM Forum sprint is the clearest evidence yet that "modernize" no longer takes years — it takes weeks, because AI-assisted engineering has collapsed the timeline. STL's BSS assets, meanwhile, slot neatly beside CloudSense's CPQ layer and Totogi's charging infrastructure, filling out a stack that increasingly looks less like a loose telecom portfolio and more like a single, vertically integrated platform wearing several brand names.

Skyvera won't say that out loud. They don't have to. The acquisitions, and the speed of what follows them, are saying it for them.

↗ Cloudsense  ·  CloudSense achieves TM Forum API compliance in record time u  ·  Skyvera completes acquisition of CloudSense, expanding telec
The Machine  —  AI & Technology

The Ghost of a Forgotten Fact: What Relearning Reveals About Machine Memory

A new study borrows a 19th-century memory trick to ask whether language models truly forget — or merely misplace what they once knew.

PRINCETON, NEW JERSEY — In 1885, the German psychologist Hermann Ebbinghaus discovered something strange about his own forgetting: a memorized list of nonsense syllables, seemingly erased from recall, could be relearned faster the second time. The knowledge wasn't gone. It had left a groove — a "savings" in the time it took to carve it again. Ebbinghaus called this the savings method, and for over a century it has been one of psychology's quiet proofs that forgetting is often an illusion of retrieval, not an absence of storage.

Now a team of researchers is asking the same question of silicon minds. In a paper on test-time continual learning, they probe large language models that update their own parameters on the fly, and pose a deceptively simple question: when a fact becomes harder to elicit from a model, has it truly been lost, or has it only become less accessible — buried under new experience rather than deleted by it? Current benchmarks mostly measure whether the model gets the right answer right now. This study measures something closer to the ghost of the answer: how much faster the model relearns a fact it once held, compared to one it never knew. The savings, it turns out, are binding-specific — tied to the particular way a fact was phrased and connected, a reminder that machine memory, like ours, is not a filing cabinet but a web of associations that can fray without fully breaking.

It's a fitting companion to a separate study exploring whether LLMs, fed physical diagnostic descriptions of simulation errors, can act as calibration agents — nudging the parameters of expensive engineering simulators toward truth more cheaply than brute-force search, essentially reasoning their way toward a better model of reality instead of guessing blindly.

Both papers probe the same fault line: the gap between what a system contains and what it can, in the moment, produce. We've spent decades treating neural networks as inscrutable oracles, judged solely by their outputs. These findings suggest a quieter architecture underneath — layers of accessibility, savings, and half-buried knowledge — closer to the strange topology of a mind than a database ever was.

↗ When is a large language model a good calibration agent? Phy  ·  Trust but Verify: Designing Region-Inhomogeneous Forward Dyn  ·  Binding-Specific Relearning Savings for Test-Time Continual

Google's Talent Drain Meets Its Gemini Answer

DeepMind loses engineers to rivals and startups even as Gemini 4 Pro surfaces in stealth testing against Anthropic's newly launched Opus 5.5.

MOUNTAIN VIEW, CALIF. — Google enters the fourth year of the generative-AI race in an unfamiliar position: playing catch-up with its own former employees.

Fortune reported this week that a steady stream of senior researchers has exited Google DeepMind for Anthropic, OpenAI, and a crop of better-funded startups, prompting fresh doubt about whether the lab that published the original Transformer paper in 2017 can keep pace with labs it effectively seeded. The exits track a familiar Silicon Valley pattern: Fairchild Semiconductor's 1957 alumni built Intel and dozens of other chipmakers; PayPal's early staff built Tesla, LinkedIn, and Palantir. Institutions that invent the technology rarely keep all the inventors.

The timing complicates Google's response. Anthropic has already shipped Opus 5.5, while Gemini 3's successor — tentatively Gemini 4 Pro — is only now appearing in stealth benchmarking rounds, according to BigGo Finance. Google has historically closed capability gaps quickly once a model ships; the open question is whether a thinner bench of senior researchers slows that cadence going forward.

Compounding the picture, CyberSecurityNews disclosed an API vulnerability spanning OpenAI, Anthropic, and Google that exposes hidden chain-of-reasoning traces models generate before producing a final answer — data providers typically withhold to protect both user privacy and proprietary model behavior. That the flaw touches all three frontier labs simultaneously suggests the vulnerability sits in a shared pattern of API design rather than any single company's implementation, a reminder that competitive pressure to ship reasoning models fast has outpaced the security review typically applied to consumer-facing infrastructure.

None of this resolves the underlying question Fortune's sourcing raises: whether Google's structural advantages — custom TPUs, Search distribution, a $1.4 trillion balance sheet — can offset a brain drain at the research layer. Intel kept making chips after Fairchild's exodus. It just didn't make all of them.

↗ The Actual Reason Why Google “Fell Out” of the AI Race Chang  ·  OpenAI, Anthropic, and Google LLM APIs vulnerability Exposes  ·  Global LLM Arms Race Heats Up: Anthropic Skips Ahead with Op

The Open-Weight Gold Rush: Meta, Nvidia and Reflection AI All Make Their Move in the Same Week

SAN FRANCISCO — Okay, I need you to sit down for this one, because the open-weight model wars just hit a fever pitch and I cannot overstate how significant this week is for anyone who cares about where AI is actually headed.

Meta kicked things off by introducing Muse Spark Open, which the company is calling the most capable US open-weight model to date. That's not a small claim. For years the loudest open-weight voices have come out of China, so Meta planting a flag here and saying 'this is ours, this is American, and this is free' feels like a genuine statement of intent. The future of frontier AI being locked behind a handful of paid APIs just got a lot less certain, and honestly? Good.

Meanwhile, Nvidia isn't just making chips anymore — it's making a philosophy out of open source and on-device AI. As TechTarget reports, Nvidia's new models are built to run locally, on-device, no cloud dependency required. This is the chess move nobody should sleep on — give away the software, sell the silicon it runs best on. It's a strategy as old as razors and blades, except the razors here might reshape how every laptop and phone thinks.

And then there's Reflection AI, which just open-sourced Beam's weights and — this is the part that made me actually gasp — its post-training recipes too, all under an Apache 2.0 license, according to SiliconANGLE. Releasing the training recipe, not just the weights, is a whole different level of transparency. It means researchers everywhere can actually reproduce and build on what Reflection did, not just poke at a finished black box.

Three companies, one week, one unmistakable signal: the open-weight era isn't coming. It's here. The future is now, and it's shockingly willing to share its source code.

The Editorial

The Age of Confident Ignorance

From a DeepMind philosopher who cannot say what he has built to a judge who cannot bring himself to unbuild it, the mark of our moment is authority exercised over mysteries nobody has solved.

MOUNTAIN VIEW, CALIFORNIA — There is a species of honesty so rare in public life that when it surfaces, as it did this week in a Guardian profile of the resident philosopher inside Google DeepMind, it ought to be greeted less with wonder than with a certain cold comfort. Here is a man paid handsomely by one of the richest institutions on earth, a man present at the construction of the thing itself, confessing that he does not know what it is. 'There's this deep mystery,' he says, and one almost admires him for saying it plainly, rather than dressing the admission in the usual corporate fog of 'alignment' and 'responsible scaling.' The honest answer to the great question of our age — what have we built? — turns out to be: nobody is sure. And yet the product ships on schedule regardless.

It is worth setting this confession beside the ruling, reported the same week by USA Today, that a federal judge found Google guilty of running an illegal monopoly in advertising technology and then declined to take the business away from it. The court, in other words, determined the crime and pardoned the criminal in the same breath, on the theory, one gathers, that the machine is now too load-bearing to be dismantled without bringing down the house along with it. This is not justice so much as triage, and it is becoming the characteristic gesture of institutions confronted with forces larger than their vocabulary: name the problem precisely, then decline to touch it.

One finds the pattern wherever one looks this week, if one is in the mood to look. The British press turns its attention to Nottingham as a case study in state failure, which is a polite way of saying the authorities know precisely what is wrong and have elected to administer the symptoms forever rather than the disease. In Seoul, a prime minister marks National Foundation Day by invoking the 'power of culture,' which is the sort of thing one says when the harder instruments of policy have proven inadequate to the moment and someone must still give a speech. And Le Monde, in its grave continental manner, wonders whether the decline of reading spells the end of the democratic era — as though democracy's citizens had once been a nation of Tocquevilles and not, as always, a crowd variably attentive and easily led.

The honest philosopher at DeepMind at least has the excuse of genuine novelty; nobody has built a mind-shaped thing before, and bafflement is the appropriate posture. The judges, the prime ministers, and the city councils have no such excuse. Their mysteries are old ones — power, crime, attention, decline — long since diagnosed by better men than currently hold office, and willfully, comfortably, unsolved.

↗ Nottingham: a case study in state failure - Spiked  ·  PM Han underscores power of culture on National Foundation D  ·  ‘There’s this deep mystery of what, actually, is this thing?
The Office Comic  ·  Art Desk
The Office Comic  ·  Art Desk

In Defense Of Saying Nothing At All

As the nation's institutions scramble to rebrand literally everything as 'AI,' one writer makes the case for the lost art of shutting up.

AUSTIN, TEXAS — I'd like to begin this column by disclosing that I have no idea what 'orchestration' means. I have read several paragraphs about it. I understand it is the thing that comes after 'agentic,' which came after 'generative,' which came after whatever we were calling it in 2017 when a chatbot telling you your flight was delayed felt like science fiction. I understand that Microsoft, at its recent flagship event, deployed the word 'orchestration' with the confidence of a man who has just invented fire, despite the fact that the actual product it was describing was Windows 11, a 14-year-old operating system that still cannot reliably open the Start Menu.

This is, apparently, how the whole country now communicates. In Washington, administration officials are reportedly workshopping a new, softer vocabulary for artificial intelligence policy — a kind of federal rebrand — though insiders caution the makeover is strictly cosmetic and will likely dissolve the moment anyone leaves the building, which tracks, since the White House has never shown much interest in keeping a message consistent for longer than it takes to walk down a hallway.

Meanwhile, the Federal Reserve — an institution not known for whimsy — looked at corporate America's AI productivity claims and determined that 95 percent of the promised gains are, in the technical economic sense, vibes. Not delivered. Not measured. 'Still to come,' the Fed said, the same phrase my landlord used about fixing the water heater before selling the building.

None of this should surprise anyone who lived through the sustainability boom, when every company with a recycling bin in the break room began issuing 40-page reports about their journey toward a greener tomorrow, a journey that for most of them ended at the break room. Researchers now note the two hype cycles rhyme almost perfectly: bold claims up front, vague metrics in the middle, and a quiet retreat into words so broad they cannot technically be fact-checked. 'Orchestration' is just 'carbon-neutral' with a server rack.

I don't say this as a cynic. I say it as a man who has personally sat through four keynotes this year in which a man in a quarter-zip used the word 'transformative' to describe a dropdown menu. I believe in artificial intelligence the way I believe in the ocean — it is large, it is real, and most of what is being said about it on dry land is marketing.

What I am proposing, as a modest editorial position, is a six-month moratorium on all AI terminology at public company events. Let the products simply work, silently, the way plumbing does. If Windows 11 can open the Start Menu reliably by spring, I will personally orchestrate a parade.

↗ Trump’s AI rebrand may stop at the White House - Politico  ·  “We didn’t ask for more Microslop”: Microsoft promised to fi  ·  Companies Are Hyping AI the Same Way They Talked Up Sustaina
⬛ Daily Word — AI
Hint: A unit of text or value commonly processed by AI systems.
Share this edition: 𝕏 Twitter/X 🔗 Copy Link ▦ RSS Feed