22 KiB
Convex-Signal Spike — Build Design
Date: 2026-06-24
Design by: Pi
Builds by: Claude
Approves: Kisa
Source: docs/convex-signal-instance-brief-2026-06-24.md
Status: Design input. This is a SEPARATE, parallel spike in repo signal-convex. The current Signal build keeps running.
1. What This Spike Proves
One question answered with a build, not a whiteboard:
Can Convex's reactive platform serve as Signal's data + backend layer (at $25/mo with a BAA) without losing the readiness model's integrity, the auth flexibility we need, or the ability to walk away if Convex doesn't work out?
The spike proves three things:
- Core readiness loop — import CSV → grade → display worklist — on Convex
- Auth seam works — the spike runs on Convex auth; swapping to Clerk is a config change, not a rewrite
- Data-access interface holds — the readiness engine doesn't call Convex directly; it calls an interface
2. Module Map (The Seams)
Six modular seams per the brief, but Koaned to ensure they're actually buildable, not over-granular.
convex/ ← Convex server-functions directory
├── schema.ts ← Convex schema (tables + indexes)
├── auth/
│ ├── AuthProvider.ts ← Interface: login, getUser, hasPermission
│ └── convexAuth.ts ← Default: Convex auth
│ └── clerkAuth.ts ← Seam: Clerk JWT validation (future)
│
├── data/ ← DATA-ACCESS LAYER (the keystone)
│ ├── Repository.ts ← Generic interface: get, list, create, update, delete
│ ├── PatientRepository.ts ← Patient + CoverageLine + Shipment queries
│ ├── DocItemRepository.ts ← DocItem CRUD
│ ├── OverrideRepository.ts ← Override CRUD
│ └── README.md ← How to swap the backend
│
├── readiness/ ← PORTABLE READINESS ENGINE
│ ├── engine.ts ← gradeLine(), rollupPatient(), computeVerdict()
│ ├── rules.ts ← load payer_rules.json, resolve rule by ID
│ ├── citations.ts ← build CascadeEntry records
│ ├── overrides.ts ← apply override, recompute, re-rollup
│ ├── payer_rules.json ← COPIED from signal/python-backend/config/
│ └── README.md ← This is portable; review before every copy
│
├── storage/
│ ├── FileStorage.ts ← Interface: upload, getUrl, delete
│ └── convexStorage.ts ← Default: Convex file storage
│ └── s3Storage.ts ← Seam: S3 (future)
│
├── automations/
│ ├── ScheduledChecks.ts ← Cron jobs: resupply reminders, expiry alerts
│ └── crons.ts ← Convex cron definitions
│
└── notifications/
├── NotificationService.ts ← Interface: sendAlert, notifyWorklistChange
└── navBotNotify.ts ← Default: NavBot pattern
└── emailNotify.ts ← Seam: SES/SendGrid (future)
src/ ← React app
├── App.tsx ← Auth provider + routing
├── convexClient.ts ← Convex client config
├── components/
│ ├── worklist/
│ │ ├── WorklistTable.tsx ← Reimagined on Convex reactive queries
│ │ └── PatientDetail.tsx ← Nested patient → device → doc view
│ ├── import/
│ │ └── CSVImport.tsx ← CSV upload → Convex mutation
│ └── auth/
│ └── AuthGate.tsx ← Auth seam adapter
│
└── lib/
└── readinessQueries.ts ← Convex-specific reactive query hooks
Analogy
Think of it like a modular synthesizer:
- Convex = the powered Eurorack case (provides power, bus, physical space)
- Readiness engine = the oscillator module (does the core work, same module in any case)
- Auth = the MIDI interface (different models, same CV/gate output)
- Data layer = the patch cables (the interface is the jack standard; swap the module behind it)
- Storage/notifications/automations = utility modules (envelope generators, mixers)
convexClient.ts+ reactive queries = the speakers (Convex-specific output; swap the whole signal chain)
The readiness engine (oscillator) is the valuable module. Everything else exists to make it produce sound. Designing for swappable patch cables means you can move the oscillator to a different case without rewiring the whole rig.
3. Data-Access Interface (The Lock-In Hedge)
This is the real design tension. Convex's core value is reactivity — useQuery() auto-re-fetches when data changes. A naive DAO interface that returns plain data loses that.
Compromise: Two-layer design.
Layer A — Convex-specific reactive queries (UI-facing)
These ARE Convex queries — they subscribe to data, auto-update, and ARE NOT portable. This is the Cost Of Doing Business with Convex.
// convex/readiness/queries.ts — Convex-specific, reactive
export const getWorklist = query({
args: { orgId: v.id("organizations") },
handler: async (ctx, args) => {
const patients = await ctx.db
.query("patients")
.withIndex("by_org", (q) => q.eq("orgId", args.orgId))
.collect();
const readinessEngine = new ReadinessEngine(ctx); // ctx.db → repository
return patients.map((p) => readinessEngine.rollupPatient(p));
},
});
Layer B — Repository interface (readiness-engine-facing)
The readiness engine never calls ctx.db directly. It calls a repository interface, which is implemented by a ConcreteRepository that wraps ctx.db. If the backend changes, implement a PostgresRepository that implements the same interface.
// convex/data/Repository.ts — portable interface
export interface Repository {
getPatient(patientId: Id<"patients">): Promise<Patient | null>;
listPatientsByOrg(orgId: Id<"organizations">): Promise<Patient[]>;
getCoverageLines(patientId: Id<"patients">): Promise<CoverageLine[]>;
createCoverageLine(line: NewCoverageLine): Promise<Id<"coverageLines">>;
updateCoverageLine(id: Id<"coverageLines">, fields: Partial<CoverageLine>): Promise<void>;
listDocItems(lineId: Id<"coverageLines">): Promise<DocItem[]>;
upsertDocItem(item: NewDocItem): Promise<Id<"docItems">>;
getOverride(patientHash: string, deviceType: string, docType: string): Promise<Override | null>;
setOverride(override: NewOverride): Promise<void>;
// ... etc
}
// convex/data/convexRepository.ts — Convex-specific implementation
export class ConvexRepository implements Repository {
constructor(private ctx: QueryCtx | MutationCtx) {}
async getPatient(patientId: Id<"patients">) {
return this.ctx.db.get(patientId);
}
async listPatientsByOrg(orgId: Id<"organizations">) {
return this.ctx.db
.query("patients")
.withIndex("by_org", (q) => q.eq("orgId", orgId))
.collect();
}
// ...
}
The readiness engine:
// convex/readiness/engine.ts — portable, takes a Repository
export class ReadinessEngine {
constructor(private repo: Repository) {}
async gradeLine(patientId: Id<"patients">, deviceType: string, planId: string) {
const lines = await this.repo.getCoverageLines(patientId);
// ... grading logic, calls this.repo for all data access
}
}
What portability costs: The reactive queries (getWorklist, getPatientDetail) ARE Convex-specific and would be rewritten if we move off Convex. The grading logic in engine.ts is fully portable. The cost of swappability is roughly 20% more code (the interface + implementation split) in exchange for 100% portable business logic. Worth it for a data-critical compliance engine.
BYOD Postgres note: Convex supports connecting an external Postgres database (bring-your-own-database). If we eventually want to own the data layer separately from Convex's runtime, this preserves that path without changing the repository interface at all — just swap which database the interface talks to.
4. Convex Schema + Readiness Model
Schema
// convex/schema.ts
import { defineSchema, defineTable } from "convex/server";
import { v } from "convex/values";
export default defineSchema({
organizations: defineTable({
name: v.string(),
clerkOrgId: v.optional(v.string()), // seam: Clerk org ID, null for Convex auth
createdAt: v.number(),
}),
patients: defineTable({
orgId: v.id("organizations"),
patientId: v.string(), // client's UUID
status: v.string(), // rolled-up status (derived)
}).index("by_org", ["orgId"]),
coverageLines: defineTable({
patientId: v.id("patients"),
deviceType: v.string(), // "dexcom_g7"
planType: v.string(), // REQUIRED: medicare | medicare_advantage | medicaid | commercial | unknown
planId: v.string(), // plan identifier from CSV mapping
gradeable: v.boolean(), // true only for CGM this phase
lineStatus: v.optional(v.string()), // derived
timingFlag: v.optional(v.string()),
}).index("by_patient", ["patientId"]),
shipments: defineTable({
lineId: v.id("coverageLines"),
dateOfService: v.string(), // YYYY-MM-DD
quantity: v.number(),
components: v.array(v.string()),
orderNumber: v.string(),
hcpcs: v.string(),
isCurrent: v.boolean(),
}).index("by_line", ["lineId"])
.index("by_line_dos", ["lineId", "dateOfService"]),
docItems: defineTable({
lineId: v.id("coverageLines"),
docType: v.string(), // swo | visit | pecos | pa | diagnosis
requiredState: v.string(), // REQUIRED | NOT_REQUIRED | NOT_EVALUATED
inputState: v.string(), // SUPPLIED | ABSENT
value: v.optional(v.string()),
source: v.string(), // csv | staff_override | computed
citation: v.optional(v.string()),
}).index("by_line", ["lineId"]),
overrides: defineTable({
orgId: v.id("organizations"),
patientId: v.id("patients"),
deviceType: v.string(),
docType: v.string(),
status: v.string(),
statusDate: v.optional(v.number()),
expiryDate: v.optional(v.number()),
confirmedBy: v.optional(v.string()),
confirmedAt: v.optional(v.number()),
reason: v.optional(v.string()),
}).index("by_override_key", ["orgId", "patientId", "deviceType", "docType"]),
cascadeEntries: defineTable({
patientId: v.id("patients"),
lineId: v.optional(v.id("coverageLines")),
docType: v.string(),
severity: v.string(), // blocker | alert
reason: v.string(),
ruleId: v.string(), // points to payer_rules.json key
clientInput: v.string(),
requirement: v.string(),
basis: v.string(),
rulesVersion: v.string(),
createdAt: v.number(),
}).index("by_patient", ["patientId"]),
uploads: defineTable({
orgId: v.id("organizations"),
fileName: v.string(),
uploadedAt: v.number(),
recordCount: v.number(),
status: v.string(), // processing | done | error
storageId: v.optional(v.id("_storage")),
}).index("by_org", ["orgId"]),
});
Function Structure
convex/
├── auth.ts ← Auth helpers (login, register, session)
├── crons.ts ← Cron job definitions
│
├── patient/
│ ├── queries.ts ← getWorklist, getPatientDetail
│ └── mutations.ts ← importCSV, deduplicate
│
├── coverageLine/
│ ├── queries.ts ← getLinesByPatient
│ └── mutations.ts ← updatePlanType, recomputeLine
│
├── docItem/
│ ├── queries.ts ← getDocItemsByLine
│ └── mutations.ts ← setDocItemOverride
│
└── readiness/
├── queries.ts ← gradeLine (read-only grade), rollupPatient
└── mutations.ts ← recomputeAll, applyOverride
5. Auth Seam
Recommended Default: Convex auth for the spike
Rationale:
- One less moving part. The spike is proving Convex on Signal. Adding Clerk adds auth complexity without proving anything new.
- Convex auth supports the same flows: email/password, Google OAuth, magic link.
- The seam stays clean — the spike's components call
AuthProvider.getUser()andAuthProvider.hasPermission(orgId). The default implementation uses Convex auth. A Clerk implementation would validate Clerk JWT tokens. - Swappable later. When/if the spike graduates, the auth backend can change without touching the readiness engine, worklist display, or any business logic.
Seam interface
// convex/auth/AuthProvider.ts
export interface AuthProvider {
getUserId(ctx: AuthCtx): Promise<string | null>;
getOrgId(ctx: AuthCtx): Promise<string | null>;
hasPermission(ctx: AuthCtx, orgId: string, permission: string): Promise<boolean>;
}
Migration path to Clerk
- Clerk issues JWT tokens → Convex validates Clerk's JWKS endpoint (already at
clerk.sttilsolutions.com/.well-known/jwks.json) - Convex's
authmiddleware checks the Clerk JWT and extracts user + org AuthProvider.getUserId()reads from Clerk's session, not Convex's- UI gate swaps from
<ConvexAuthGate>to<ClerkAuthGate>— same props, same behavior
6. Readiness Model Reuse Strategy
Copy, not share (for the spike)
The readiness engine + payer_rules.json are copied into signal-convex/convex/readiness/. Rationale:
- Isolation. The spike is parallel. A shared package creates coupling between a production app and an experiment.
- Speed. Copying is faster than extracting a package. The spike needs to ship fast.
- The readiness model is stable. It was locked on 2026-06-23 and won't change during the spike.
If both instances graduate
Extract a shared npm package (@sttil/readiness-engine) that both repos import. The engine is already designed to be portable (Repository interface, no I/O dependency) — this extraction is a mechanical step, not a redesign.
What changes for Convex
The logic is identical. What changes is how it's called:
- Python → TypeScript. The engine is rewritten from Python to TypeScript (Convex is TS-native). The logic is the same.
- Convex types for IDs. Instead of string patient_ids, Convex uses typed
Id<"patients">. - Repository implementation. Instead of Supabase SQL queries, the ConvexRepository uses
ctx.db.get/query/patch/insert/delete. - No SQL migrations. Convex schema changes are instant (no explicit migrations).
7. Repo Structure + Workflow Integration
Repository
signal-convex/ ← NEW repo, separate from signal/
├── convex/ ← Convex server-functions directory
│ ├── schema.ts
│ ├── auth.ts
│ ├── crons.ts
│ ├── data/
│ ├── readiness/
│ ├── storage/
│ ├── patient/
│ ├── coverageLine/
│ ├── docItem/
│ └── notifications/
│
├── src/ ← React app (Vite + TypeScript)
│ ├── main.tsx
│ ├── convexClient.ts
│ ├── App.tsx
│ └── components/
│
├── package.json
├── convex.json ← Convex project config
├── tsconfig.json
├── vite.config.ts
├── CLAUDE.md ← Project instructions (like signal/CLAUDE.md)
├── context/
│ └── current-state.md ← Optional: state-sync for the spike
├── docs/
│ └── convex-signal-design-2026-06-24.md ← This doc
│
├── .github/
│ └── workflows/ ← CI: typecheck, lint, test on push
│ └── ci.yml
│
└── README.md
Git workflow
- Source of truth: Forgejo (
git.forgejo.sttilsolutions.com:sttil/signal-convex) - Mirror: GitHub (via
git-pushskill convention) - Branch strategy:
mainis the active spike. Feature branches for each phase. - Commit discipline: research-versioning patterns apply. The readiness engine and payer_rules.json have an explicit COPY-FROM marker in commit messages.
Operating model
- Pi designs → Claude builds (same as the current workflow)
- state-sync applies:
signal-convex/context/current-state.mdfor spike state - ship-status-ledger extended: add spike items alongside current-build items
- research-versioning skill: covers the new repo once the Stop hook is added
8. Compliance Touchpoints + BAA Gate
PHI boundary (copied from the existing model, same rules)
| Data | Status | Reason |
|---|---|---|
| patient_id (client UUID) | PII-level identifier | Same as current build — hashed in logs, only crosswalk key |
| Patient names, SSNs, DOBs, contacts | NEVER INGESTED | Architecture contract, same as current build |
| Device type, DOS, quantity | Non-PHI | Standard supply chain data |
| Payer name, plan type | Non-PHI | Payer policy data |
Gate: BAA required BEFORE real PHI
The spike currently runs on mock/synthetic data. Before ANY real patient data touches it:
- Convex BAA must be signed by Kisa (email drafted, costs known: $25/mo Professional)
- sttil-compliance-check gate: run the compliance check on the Convex setup
- Attorney review of the Convex BAA terms vs AWS BAA terms
- Data-handling doc update: add Convex to the compliance docs in
signal/docs/compliance/
Built-in audit trail
Convex's Professional plan includes an audit log. No extra build needed for basic audit. The cascade entries (citations table) serve as the business-level audit trail.
9. Phased Spike Plan (Timeboxed)
Total estimate: ~40 engineer-hours (Claude with Kisa review). Timeboxed to 5 calendar days of focused work — can be parallelized with the readiness-model build on the current Signal repo.
Phase 1 — Core Readiness Loop (prove the thesis, ~20h)
What ships: CSV in → grade → worklist display. Running on Convex auth.
| Step | What | Hours |
|---|---|---|
| 1 | Create signal-convex repo, scaffold Convex + Vite project |
1 |
| 2 | Define schema (organizations, patients, coverageLines, shipments, docItems) | 2 |
| 3 | Implement Repository interface + ConvexRepository | 3 |
| 4 | Port readiness engine from Python to TypeScript (module 1: gradeLine + rollupPatient) | 6 |
| 5 | Copy payer_rules.json, build rules loader |
1 |
| 6 | Implement CSV upload mutation (parse → store → grade → cascade) | 3 |
| 7 | Build worklist display with reactive queries (patient list + status) | 3 |
| 8 | Implement auth: Convex auth gate, org-aware queries | 1 |
Gate: Kisa reviews Phase 1 before Phase 2 starts.
Phase 2 — Auth Seam + Device-Keyed Override (~12h)
What ships: Override + recompute + re-rollup. Auth seam proven swappable.
| Step | What | Hours |
|---|---|---|
| 1 | Implement Override schema + repository | 1 |
| 2 | Add override mutation + recomputeLine + re-rollupPatient | 3 |
| 3 | Build override UI in patient detail view | 2 |
| 4 | Implement AuthProvider interface + ClerkAuthProvider stub | 2 |
| 5 | Write tests: gradeLine with known inputs, override+recompute, dedup | 3 |
| 6 | Verification: swap to ClerkAuthProvider in config, confirm everything still works | 1 |
Phase 3 — Hardening + Remaining Seams (~8h)
What ships: File storage (Convex), automations (cron reminders), notifications (NavBot), documentation.
| Step | What | Hours |
|---|---|---|
| 1 | File storage seam: upload documents, attach to patients | 2 |
| 2 | Automation: schedule resupply-reminder checks | 2 |
| 3 | Notification: NavBot-compatible alert on new-lead / override-action-needed | 2 |
| 4 | README, CLAUDE.md, compliance notes, spike retrospective | 2 |
10. What Claude Builds First
Claude builds Phase 1, Steps 1-8, in order
The priority is proving the core thesis: CSV-in → grade → worklist-display on Convex. Nothing else matters if this doesn't work.
Explicitly NOT in Phase 1:
- File storage (Phase 3)
- Automations (Phase 3)
- Notifications (Phase 3)
- Override + recompute (Phase 2 — need the core loop first to verify baseline correctness)
- Clerk auth (Phase 2 — prove the spike on Convex auth first, swap later)
- Export, multi-device display, or non-CGM grading
Before Phase 1 starts
- Kisa: send the Convex BAA email (item 8 in ship-status ledger)
- Claude + Kisa: verify Convex Professional plan includes the BAA (already verified 2026-06-24 — reconfirm at sign-up)
- Create
signal-convexrepo on Forgejo + GitHub
11. Open Questions Resolved
Convex auth vs Clerk for the spike
Decision: Convex auth as the default. The auth seam means Clerk is a config swap, not a rewrite. Reduces spike complexity without compromising future flexibility.
Dedup key (carried from readiness-model decision)
Pending Kisa's decision — same patient + device + DOS under two order numbers → one worklist line or two? The spike design supports both. The shipments table has orderNumber as a displayed field (not a dedup key), and the dedup logic lives in readiness/engine.ts. When Kisa decides, one line changes in the engine.
Data-access interface boundary
Decision: Repository interface between the readiness engine and the database. The reactive query layer (Convex-specific convex/patient/queries.ts) is the cost of Convex and is NOT portable. The tradeoff is documented in Section 3.
12. Effort Estimate Summary
| Phase | Hours | Builds | Key Risk |
|---|---|---|---|
| Phase 1 | ~20h | Claude | Porting readiness logic from Python to TS |
| Phase 2 | ~12h | Claude | Override + recompute complexity |
| Phase 3 | ~8h | Claude | Notification integration |
| Total | ~40h | Claude | Timeboxed to 5 calendar days |
Key risk: The readiness engine rewrite from Python to TypeScript. The logic is stable and documented (readiness-model-brief-2026-06-23.md), but porting requires attention to edge cases (enum mapping, date handling, the dedup decision). Mitigation: code-auditor review after each Phase-1 step.