Lock Identify rules to a typed Convex menu

Spreadsheet authors invent new words every week. I lock Identify to a typed Convex menu of metrics, scopes, and actions so the reducer stays boring.

Sander Korf3 min read
convextypescript

Identify turns a clinician spreadsheet into a Convex engine. The sheet is readable. It is also a dialect that mutates every week. "If mostly little" lands in a row comment. "Go back to SL B" is a pink highlight. If the reducer tries to mirror that chaos, every deploy becomes archaeology.

I needed a closed vocabulary the engine can actually execute. Authors can keep talking in discovery language. Engineers translate before seed. The conversation about a new metric happens at the schema, not inside a switch statement at 2 a.m.

What the menu is allowed to say

Convex validators define the typed menu the reducer reads. Spaciousness answers are no, unsure, little, or yes. Item sources are fixed or remainingOfGroup. Metrics include yesCount, littleCount, positiveCount, visitCount, and the rest of a closed set. Operators pick an op, a scope (thisStep, wholeRun, items, and friends), and a number.

Actions are goToStep, continueRemainders, replayStep, restartFrom, or end with hypothesis or unsuccessful. That is the whole verb list. If someone wants "nudge the clinician" as an action, they are asking for a product change, not a string in a cell.

Steps are a discriminated union: present, compare, exercise, message, end. Each kind carries only the fields that kind needs. A compare step cannot accidentally carry exercise timing because the validator will not allow it. That is the cheap win. You stop representing illegal states, so the reducer stops defending against them.

When clauses stay small on purpose

When clauses are either always or { metric, op, n, scope }. Rule rows evaluate in order. First match wins. Named Starting Low A and B routing still lives in engine code because the typed menu has no AND combinator. That is intentional. I would rather keep two well-known forks in TypeScript than invent a miniature programming language inside Convex documents.

Prototype fields from the sheet stay live in seed data. I did not invent a second menu beside the validators. Two menus is how you get a UI that says one thing and a reducer that does another.

export const vAction = v.union(
	v.object({ kind: v.literal('goToStep'), stepId: v.string() }),
	v.object({ kind: v.literal('continueRemainders'), targets: v.array(vRemainderTarget) }),
	v.object({ kind: v.literal('replayStep'), stepId: v.string() }),
	v.object({ kind: v.literal('restartFrom'), stepId: v.string() }),
	v.object({ kind: v.literal('end'), result: v.union(v.literal('hypothesis'), v.literal('unsuccessful')) })
);
 
export const vWhen = v.union(
	v.object({ kind: v.literal('always') }),
	v.object({ kind: v.literal('when'), metric: vMetric, n: v.number(), op: vOp, scope: vScope })
);

Free text is not a rule language

Authors like a free-text rule language. Tests hate it. I skipped a visual graph editor for v1. The sheet is still the discovery tool. The typed menu is the contract. When someone asks for a metric that is not listed, you add it to the validator and the reducer together, or you say no. Saying no is cheaper than a string matcher that "almost" understands the sheet.

The UI gets thin because it can only offer what the menu allows. Dropdowns instead of comment archaeology. Clinicians still see familiar language in training docs. The engine sees literals. Those two views meeting at seed time is the workflow. They should not meet inside a mutation that string-matches a highlight color.

If you are porting spreadsheet logic into code, lock the vocabulary first. The reducer gets boring. The tests get useful. That boredom is the feature.


Happy coding! Sander