Linear: [AERIE-2729](https://linear.app/builder-team/issue/AERIE-2729/add-public-api-read-routes-for-site-internet-profile-cleanliness)
## Testing contract
### What this PR delivers
Three new read-only public API v2 routes, so Rhodes data that only the Rhodes MCP could read is also available to API consumers:
- GET /v2/portfolio/sites/{siteRef}/internet: the site's internet operating profile.
- GET /v2/portfolio/sites/{siteRef}/cleanliness: the site's cleaning and consumables operating profile.
- GET /v2/portfolio/sites/{siteRef}/decision-log: the site's recorded decisions, newest first.
Edu Ops asked for all Rhodes data to be reachable through the Rhodes DSS, which reads through this API. These three were the Rhodes tables with no API route.
No writes, no schema or data migration, no change to existing routes.
### Who uses it and where
API key holders with operations.portfolio.read, calling the public API v2 directly or through the Rhodes DSS. The routes appear in the generated OpenAPI spec, /v2/meta and the API docs page, under "Portfolio Site Profile". No in-app UI changes.
### Conditions needed
- An API key with operations.portfolio.read.
- A site with a recorded internet profile, a recorded cleanliness profile and at least two decision-log entries.
- A site with none of the three.
- A second key without operations.portfolio.read (for the 403 case).
- For the bound: a site with more than 200 decision-log entries.
### Expected behavior and examples
1. Internet profile. Returns site, recorded, status, primary and backup provider with download/upload Mbps, backupFailoverMethod, and issueContact {name, phone, email}. Unset values are null.
Example: a site with primary provider "Fiber Co" at 1000/500 Mbps and automatic failover returns those values with backupProvider: null and recorded: true.
2. Cleanliness profile. Returns site, recorded, status, cleaningResponsibility, primaryProvider, serviceStartDate, servicePattern, cadence, consumables fields, pestControlProvider and two contacts. serviceStartDate can be an ISO date or the literal "N/A".
3. No profile recorded. Both profile routes return 200 with recorded: false and every other field null, not a 404.
4. Decision log. Returns entries newest first, each with recordedAt, body, relatedField (null when not recorded), author.displayName and decisionMaker.displayName, plus truncated.
- At most 200 entries are returned; truncated is true when older ones exist.
- Only display names are returned. User IDs and emails are never included, and a user whose stored name is an email address appears as "Aerie user" (same rule as site notes).
- A site with no decisions returns entries: [], truncated: false.
5. Access. No key returns 401; a key without operations.portfolio.read returns 403; an unknown site returns 404.
### Limits and unanswered questions
- Not included: driveFolderId, wrikeFolderId, hubspotProgramCode. Edu Ops also asked for these. portfolioDomain.test.ts asserts the integrations route must not return the Drive and Wrike IDs, so I treated that as a deliberate guardrail and left it alone. Whether to expose them needs an owner decision.
- Capability choice. I gated all three on operations.portfolio.read, like the other site-profile reads. The profiles include contact names, phones and emails; say if a narrower capability is wanted.
- Decision log is bounded, not paginated. 200 newest entries with a truncation flag. If sites routinely exceed that, cursor pagination should follow.
- Not exercised against a deployed environment; verified with the Convex test harness only.
## Verification
- chat: tsc --noEmit and tsc -p convex/tsconfig.json --noEmit clean.
- vitest run convex/publicApi lib/public-api app/api: 78 files, 894 tests pass, including two new tests in portfolioDomain.test.ts.
- pnpm lint passes (read-bounds check included; the decision-log read is index-scoped and bounded with .take).
## Business Value
Lets Edu Ops' agents read every Rhodes site record through one API instead of needing the MCP for three of them, which is what unblocks the Rhodes DSS from being "all of Rhodes".
## Manual Effort Estimate
Proposed: about 1 day by hand (learning the v2 route, schema and agent-catalog pattern, three routes, tests). Keval to confirm or adjust.
🤖 Generated with [Claude Code](https://claude.com/claude-code)