feat/one-api
needs attentionviewing older commitc2ee0da · incrementalPR #326reviewed 2026-07-21 02:17 UTC4H · 7M · 7L · 5I- Purpose
- Establish the One-API program: encode the public-by-default direction as a .claude/ rule (replacing any ADR), and produce the W1 groundwork — route-metadata inventory and merged public-id prefix registry — that every later parity wave builds on.
- Goal
- one-api.md is the SSOT for the One-API decision, wire grammar, operation grammar, and wave plan; the PublicIdPrefix registry is merged into shared-kernel; the route inventory classifies all 245 routes with proposed visibility/auth/scopes.
- Sub-goals
- SG-1: Encode the decision in .claude/ — one-api.md rule (decision, wire grammar, operation grammar, parity classes, wave plan)
- SG-2: Route classification script + inventory — parse the three routers, emit all routes with proposed metadata
- SG-3: Merge PublicIdPrefix registries — shared-kernel is the registry, utility base.ts re-exports, public schema regexes derive from constants
- SG-4 (stretch): Distill load-bearing ADRs (016, 018-type-first, 020) into their owning rules and retire docs/ADRs/
- SG-N: Framework improvements — fold learnings back into CLAUDE.md and skill files
- What
- This incremental window adds the 'public-by-default (ratified 2026-07-20)' direction as principle #6, adds three major new sections to one-api.md (resource shape & nesting, canonical wire examples, canonical contract form), updates the classify-routes.ts APP_CARVE_OUTS constant and proposedFor() default, and bulk-reclassifies ~30 routes in route-inventory.json from app/app-fast to public/beta.
- Why
- The public-by-default direction was ratified 2026-07-20 — the dashboard is just another API client, so all non-carve-out capabilities should be exposed publicly under a beta stability label that requires the full public bar (validator, curated errors, docs, IDOR pass) before going live.
- Areas
- .claude/rules+580−10packages/shared-kernel+74−22packages/api/src/schemas+20−43packages/api/src/__tests__+68−0scripts/one-api+920−440domains/utility/src/schema+5−27packages/database/src/schema+5−3docs/ADRs+0−250
- Blast
- 90 files, +6822/-13652 cumulative vs main. Primary areas: .claude/rules (new one-api.md + updates to 5 existing rules), packages/shared-kernel (PublicIdPrefix registry merge), packages/api schemas (regex-from-constant), scripts/one-api (classifier + inventory), docs/ADRs (retired). No application route changes; no new migrations; no UI changes.
Findings · 26
correctness5
APP_CARVE_OUTS omits catalog CRUD — contradicts rule #6 it implements
scripts/one-api/classify-routes.ts:238
Rule #6 in one-api.md names three forced carve-outs: '(a) platform-admin power (admin-organizations, catalog CRUD) stays app + platform:admin'. The classifier implements this as APP_CARVE_OUTS = new Set(['admin-organizations', 'auth']) — 'catalog CRUD' is absent. As a result, makes (POST/PATCH/DELETE /api/makes), metric-types (POST/PATCH/DELETE /api/metric-types), and tariff-jobs (POST /api/tariff-jobs) are proposed as visibility:'public', auth:['machine','session'] in the inventory despite handler comments explicitly marking them 'admin-required: Batu-owned catalog (no orgId)'. These are service-role-only writes by RLS. The inventory is the W2 registry seed — wrong proposals propagate to withApiAuth enforcement.
Wire example numerical inconsistency — intermedio kWh differs between bills and energy-summary
.claude/rules/one-api.md
In the bills GET example, concepts.energia_intermedia_kwh is '98352.00'. In the energy-summary GET example, cfe_period_breakdown.intermedio.total_consumption.value is 98352.2. Presented as canonical 'shipped-accurate' reference for the same site+period, the values differ and energy-summary totals (98352.2 + 41808.0 + 18240.0 = 158400.2) don't match bill totals. The discrepancy looks like a factual error in what is explicitly billed as shipped-accurate data.
Unit casing inconsistency between examples not annotated as a wart
.claude/rules/one-api.md
Bill example concept_meaning uses 'kWh'/'kW' (mixed case). Energy-summary value objects use 'kwh'/'kw' (lowercase, matching the shipped mapper). The 'shipped warts' annotation mentions float-vs-string and data_quality scale but omits unit casing. For a document committed to 'warts left visible on purpose', this looks like a typo rather than a known shipped difference.
409 camelCase (expectedVersion/actualVersion) correctly documented as shipped internal
.claude/rules/one-api.md:319
The 409 comment correctly flags this as '(shipped internal; W3 target on public writes)'. Only gap is it's not listed in the W3 warts block — see related medium finding.
infra surface handled correctly by separate branch — APP_CARVE_OUTS is not involved
scripts/one-api/classify-routes.ts:250
Carve-out (c) — service-to-service routes stay infra — is handled by the surface==='infra' branch in proposedFor(). All six infra routes in the inventory correctly show visibility:'infra', auth:['service'], stability:'coordinated'. No issue.
security7
Catalog CRUD write routes proposed public+machine — platform-admin gate bypassed
scripts/one-api/classify-routes.ts:238
makes, metric-types, integrations write routes are Batu-owned catalog tables with no orgId (RLS: authenticated read-all, service_role write). Their live handlers use the database client directly (service role, bypassing RLS), not createRLSDb. Proposing them as public+machine means any API key holder with the relevant scope could create/update/delete platform catalog entries (device brands, metric variable types, integration connectors). The required IDOR pass tests org isolation, not role elevation — it would not catch this gap. Fix: add 'makes', 'metric-types', 'integrations' to APP_CARVE_OUTS and add platformAdmin checks to write handlers.
Organization create/delete/update proposed public+machine — org provisioning power exposed
scripts/one-api/route-inventory.json
POST /api/organizations, DELETE /api/organizations/:publicId, PATCH /api/organizations/:publicId are classified public+machine with scopes organizations:read/write. Creating a new org via an API key is platform-provisioning power — functionally equivalent to admin-organizations, which is correctly carved out. A machine key belongs to an existing org; using it to create a new org or delete/update another org is privilege escalation that org-scoping in resolveCurrentOrg won't prevent (it reads the machine JWT's org membership, has no opinion on org creation or cross-org updates). The IDOR pass doesn't test whether a machine key can provision new orgs. These routes need explicit rationale or a carve-out.
Billing subscription management (activate, change-plan) proposed public+machine — Stripe session cross-org binding not enforced
scripts/one-api/route-inventory.json
POST /api/billing/activate and POST /api/billing/change-plan are proposed public+machine. The activate handler fetches a Stripe Checkout Session by ID from the caller, then applies the resulting stripeCustomerId to the org from the machine key's JWT. There is no check that the Stripe session's client_reference_id or customer matches the calling org's existing stripeCustomerId. A confused-deputy scenario: if org A's API key obtains a Stripe session minted for org B, it could activate B's subscription under A. Additionally, billing/portal and billing/change-plan allow machine callers to change plans gated only by org membership role:'member'. These need explicit ratification separate from the general IDOR pass.
tariff-jobs:create proposed public+machine despite being a global no-org write operation
scripts/one-api/route-inventory.json
POST /api/tariff-jobs is proposed public+machine with scope tariff-jobs:write. Handler comments state 'admin-required — global CFE scrape trigger, no org/RPU/secret'. The handler uses `database` (service role, not createRLSDb). tariff_jobs is service-role-write by RLS — no orgId, no org scoping. Any API key holder with tariff-jobs:write could trigger global tariff scrape jobs. This is a direct consequence of 'tariff-jobs' not being in APP_CARVE_OUTS. Fix: add to carve-out set.
Profile routes with machine auth will silently fail for machine callers — misleading inventory
scripts/one-api/route-inventory.json
GET/PATCH/DELETE /api/users/:publicId are proposed public+machine. The handler enforces self-access with profile.authId !== authReq.auth.userId. For machine key callers, authReq.auth.userId is a synthetic 'apk_<id>' that will never match any profile.authId (a Supabase UUID). Every machine call returns 403. The inventory is functionally incorrect for machine actors — it will produce misleading API docs. Routes need either session-only auth carve-out or a parallel machine-actor path.
Beta stability carries no runtime enforcement — IDOR pass requirement exists only in docs
.claude/rules/one-api.md:48
Rule #6 requires the full public bar (validator module, curated errors, docs-grade summary/description, IDOR pass) before publicizing a route. The beta label is purely documentation metadata with no CI gate or code-generation guard preventing a beta route from going live without the IDOR pass. The W2 withApiAuth work reads the registry — beta routes could become callable before BAT-275 is complete. Consider an 'idor_status' field on RouteMeta or a pre-W2 CI gate.
404 body echoing queried ID is benign for org-gated authenticated callers
.claude/rules/one-api.md:318
The canonical 404 wire shows the queried id in the message. The rule correctly notes 'cross-org probes are indistinguishable from not-found, by design'. Machine keys are scoped to one org — seeing their own queried id reflected is not an enumeration risk. No change needed.
conventions5
Canonical contract form prescribes validators/ directory — conflicts with canonical-form.md's 3-dir API Surface Pattern
.claude/rules/one-api.md:344
one-api.md §'Canonical contract form' prescribes a 4-directory layout: contracts/ + validators/ + handlers/ + mappers/. canonical-form.md §'API Surface Pattern' (always-loaded SSOT) prescribes only 3: contracts/ + handlers/ + mappers/. The validators/ directory does not exist in the codebase today (validators live in apps/platform/src/api/utils/). Two authority sources for the API surface layout now differ structurally. Per CLAUDE.md: 'When the codebase deviates, the canonical form wins.' The contract-form rules belong in canonical-form.md; one-api.md should defer the validators/ layout to a W2-deliverable note (as it does for RouteMeta/routeMeta()) rather than stating it as the current target.
one-api.md self-describes as 'twin' of canonical-form.md — duplication anti-pattern
.claude/rules/one-api.md:340
The section header reads 'Canonical contract form (the file-level target — contract-layer twin of canonical-form.md)'. CLAUDE.md states 'Don't document what you can derive' and skills/rules share one source of intent (canonical-form.md). Creating an explicit twin introduces the duplication risk the tier system prevents: two files now describe the API surface layout and they already diverge (validators/ directory). The contract-form rules should be distilled into canonical-form.md; one-api.md should reference them, not duplicate them.
Evidence reference to .branch/reference/ will be a dead link post-merge
.claude/rules/one-api.md:11
The status block cites 'Evidence basis: the 2026-07 canonical scaffolding audit (.branch/reference/ on feat/one-api)'. .branch/ is gitignored by convention; the files were untracked in commit 576fbd69. Any engineer reviewing the rationale cannot reach the cited source after branch merge. Either remove the parenthetical or replace with 'internal audit, 2026-07' without a path.
route-inventory.json checked in as a generated artifact without a lifecycle note
scripts/one-api/route-inventory.json
The file self-describes as 'do not hand-edit' but there is no gitignore entry or rule noting its lifecycle. As a W1 seed that becomes stale whenever a route is added/modified, it will produce noisy diffs. Since the intent is for it to graduate into RouteMeta on contracts (W2), either (a) add a note in one-api.md about its expected lifetime, or (b) gitignore it and regenerate on demand.
.branch/ deletion is correct per convention
.branch/scope.md
scope.md and intent.md deletions are correct hygiene — gitignored working files should not be tracked. Durable conclusions are encoded in one-api.md. No knowledge lost.
tests3
No unit tests for proposedFor() carve-out logic — 30+ route reclassification unguarded
scripts/one-api/classify-routes.ts:240
The proposedFor() function is pure and controls the classification of all 245 routes. It was changed significantly in this diff (APP_CARVE_OUTS added, default flipped to public+beta). No tests exist. A regression here (e.g., accidentally removing 'auth' from APP_CARVE_OUTS) silently reclassifies auth routes to public+machine. The codebase has established the pattern: extract pure logic to scripts/lib/{name}-core.ts, test via scripts/__tests__/{name}-core.test.ts. Acceptable for W1 planning; should be addressed before W2 when the inventory becomes a registry gate.
No CI check that route-inventory.json matches script output
scripts/one-api/route-inventory.json
The JSON could silently drift from the classifier if a contract is added/changed and the developer forgets to re-run the script. A lightweight CI assertion (re-run classify-routes.ts, diff the output) would catch this. Acceptable for W1 planning; worth adding before the inventory is used as a W2 registry input.
Test gaps are acceptable for this W1 planning branch
scripts/one-api/classify-routes.ts
W1 deliverable is the rule file and inventory, not application code. The right time for proposedFor() test extraction is W2 when the inventory becomes a registry gate. The medium finding above records the gap; this info confirms it is a known accepted tradeoff at this stage.
improvement6
403 FORBIDDEN missing from 'Failure shapes — the complete set'
.claude/rules/one-api.md:310
The section claims 'the complete set (nothing else ever leaves the API)' and shows 400, 401, 404, 409, 500. However 403 (FORBIDDEN) is a live shipped code in packages/api/src/responses/codes.ts:37 with a helper (forbidden()) — returned by ensureModuleEntitled and several handlers. 429 is also live: the rate-limit middleware at /api/v1/[...ts-rest]/route.ts fires before ts-rest dispatch and emits { error: 'rate_limited', ... }. Both are missing from the 'complete set'. Either add them or soften the section title to 'common failure shapes'.
409 CONCURRENCY_ERROR uses camelCase (expectedVersion, actualVersion) — undocumented wart on a public-surface example
.claude/rules/one-api.md:320
The failure shapes example shows camelCase on the 409 body. The wire grammar section says snake_case on public routes. The 409 comment correctly marks this 'shipped internal; W3 target on public writes' but the 'W3 ratification list' wart annotation (lines 245-247) only covers energy-summary warts — the 409 camelCase is not in that list. Add it to the annotated warts block so implementers don't copy-paste and expect consistency.
beta → stable promotion criteria undefined — 'ratification' has no entry bar
.claude/rules/one-api.md:49
Principle #6 says routes are 'promoted to stable per capability at ratification' but neither this rule nor the wave plan defines what ratification means: who approves, what evidence is required, where the decision is recorded. A minimal clarification (e.g. 'ratification = explicit tech-lead sign-off in a Linear issue, recorded in the capability's contract metadata') prevents beta routes from going stable by inertia.
data_quality scale wart (0–100 wire vs 0–1 internal) has no tracking reference or new-route policy
.claude/rules/one-api.md:245
The annotation says 'data_quality here is 0–100 while the internal seam is a 0–1 ratio — pin one' but gives no Linear issue reference, migration path, or guidance for new routes. A new energy or savings route author won't know which scale to use. Add a one-line policy: 'new routes MUST emit 0–1; the 0–100 in energy-summary is a shipped wart being normalized in W3' (or add a BAT-XXX tracking reference).
concept_meaning list-root placement decision not documented
.claude/rules/one-api.md:192
concept_meaning appears at the list envelope root rather than inside each data item. The rule doesn't explain why. The bills contract confirms this is intentional (GET /v1/bills/:id embeds it inside the single resource). Add a one-line rationale: 'concept_meaning is at the list root (not per-item) — the legend is stable for the response and repeating it N times per page wastes bytes' so future open-map capabilities know to follow the same pattern.
'object' on energy_summary aggregate has no registry entry — inconsistent with sub-resource rule
.claude/rules/one-api.md:214
The wire grammar applies 'object' to resources with their own registry id+object. energy_summary is an aggregate/value-object with no PublicIdPrefix entry. Placing 'object': 'energy_summary' on it is inconsistent with the rule's nesting table. Either add a clarifying note (aggregates may carry 'object' without a registry entry to enable polymorphic clients) or remove 'object' from the energy_summary example.
History · 47 commits
- 82bb5b9blockedincremental5H · 5M · 4L2026-08-12 01:48
- 90aa3d5needs attentionincremental1H · 5M · 3L2026-08-11 19:37
- 29d19a0needs attentionincremental1H · 5M · 9L2026-08-11 17:41
- 9bd8a0cneeds attentionfull0H · 5M · 9L2026-08-11 02:14
- 62ec3f7needs attentionincremental2H · 5M · 6L2026-08-10 22:51
- f93bca9needs attentionincremental2H · 5M · 8L2026-08-10 17:51
- 052db6fneeds attentionincremental1H · 3M · 4L2026-08-09 21:13
- 45699caneeds attentionincremental0H · 7M · 11L2026-08-09 17:44
- b843d8aneeds attentionincremental1H · 7M · 9L2026-08-09 04:05
- e1757b8needs attentionincremental0H · 3M · 6L2026-08-05 02:11
- 7a762faneeds attentionincremental2H · 5M · 5L2026-08-05 01:25
- 3300a60needs attentionincremental2H · 4M · 7L2026-08-04 19:06
- 0c8a7f5needs attentionincremental0H · 4M · 9L2026-08-04 18:15
- 345f42eneeds attentionincremental2H · 6M · 9L2026-08-04 17:28
- 8338a9aneeds attentionincremental5H · 14M · 14L2026-08-04 00:33
- 41be4c3needs attentionincremental0H · 5M · 7L2026-08-03 23:49
- 5ed593dneeds attentionincremental1H · 6M · 6L2026-08-03 21:32
- b333e25needs attentionincremental4H · 9M · 8L2026-08-03 21:00
- 5642cccneeds attentionincremental2H · 3M · 2L2026-08-03 20:17
- 73b0b39needs attentionincremental3H · 10M · 13L2026-07-31 18:29
- b19852eneeds attentionincremental0H · 1M · 5L2026-07-29 05:04
- 3845205needs attentionincremental3H · 6M · 4L2026-07-29 04:47
- eb8eb50needs attentionincremental0H · 1M · 2L2026-07-29 03:03
- f4720a3needs attentionincremental6H · 8M · 7L2026-07-29 02:54
- f8d341ablockedincremental2H · 2M · 5L2026-07-29 00:00
- a7f1a64needs attentionincremental2H · 8M · 8L2026-07-28 18:41
- 738b60bblockedincremental3H · 6M · 5L2026-07-28 00:46
- 2c248b6needs attentionincremental8H · 12M · 8L2026-07-27 23:23
- 1346cc0needs attentionincremental2H · 8M · 6L2026-07-27 20:15
- 0716018needs attentionincremental2H · 11M · 12L2026-07-27 19:22
- 215cd2dneeds attentionincremental3H · 6M · 5L2026-07-27 17:04
- ec46958needs attentionincremental0H · 3M · 5L2026-07-27 16:51
- de7b337blockedincremental4H · 9M · 14L2026-07-27 06:36
- b1bb9c0needs attentionincremental1H · 2M · 4L2026-07-27 05:09
- 4701d11needs attentionincremental0H · 4M · 3L2026-07-27 04:44
- e1626c4needs attentionincremental3H · 9M · 10L2026-07-27 03:21
- 195f198needs attentionincremental3H · 3M · 3L2026-07-25 01:22
- 42c7358safeincremental0H · 0M · 0L2026-07-22 20:46
- 85b9018needs attentionincremental0H · 1M · 6L2026-07-21 23:51
- a7b2a9aneeds attentionincremental0H · 9M · 12L2026-07-21 18:49
- c2ee0daneeds attentionincremental4H · 7M · 7L2026-07-21 02:17current
- e8ffa5eneeds attentionincremental4H · 7M · 5L2026-07-21 01:33
- a2d2a54needs attentionincremental2H · 7M · 3L2026-07-21 00:51
- 576fbd6needs attentionfull1H · 6M · 7L2026-07-21 00:35
- d3465e8needs attentionincremental1H · 7M · 10L2026-07-21 00:23
- dc794a7needs attentionincremental0H · 5M · 5L2026-07-20 23:46
- 9082773needs attentionfull1H · 3M · 3L2026-07-20 23:13