← Blog

Shipping Forms

Forms is a collection primitive for conversations — the fifth memory subsystem in glove-memory. This is the record of what we decided and why, and of the five defects a fifty-cent eval found that reading the code had not.

Every agent that collects something structured — an intake, an onboarding, a claim, a booking — ends up rebuilding the same four things badly. It keeps partial answers in the transcript, where they are one summarisation away from gone. It asks in a fixed order, so a user who volunteers question six while being asked question two gets asked question six again later. It cannot tell “not answered” from “does not apply”. And when the user corrects themselves, the old answer is overwritten and the correction is unappealable.

Forms exists to make those four failures structurally impossible rather than discouraged by a prompt. What follows is the honest version of how it got there — including the parts we got wrong on the first pass.

Three shapes, three owners

Almost every question about this subsystem answers itself once you know which representation you are holding. A FormDef is code in your repo — zod schemas, predicates, executors — and it is never serialised. compileForm turns it into a cached index. A FormInstance is the only thing storage ever sees. A FormView is rebuilt on every tool call and is the only thing the model reads.

The model never sees a definition. That single boundary is what lets the definition carry arbitrary TypeScript while the agent-facing surface stays a flat list of rows with a status each.

The decisions

Definitions are code, not JSON

The tempting design is a JSON schema for forms, stored in a table, editable at runtime. We did not do that, and the reason is that the interesting parts of a form are not data. An applicability rule is a predicate. A validation is a zod schema. A side effect is an async function with access to the rest of memory. Encoding those as data means inventing a language for them, and that language will be worse than the one you already have.

Colocating them also buys type threading. Every .field() widens the accumulated values type, so a predicate written on the third field of the second step already knows about everything declared before it.

forms/pi-intake.tstypescript
.field("incidentType", {
  schema: z.enum(["vehicle", "premises", "medical"]),
  label: "Type of incident",
})
.field("vehicleCount", {
  schema: z.number().int().min(1).optional(),
  label: "Vehicles involved",
  // `v.incidentType` narrows to the enum union — not `unknown`, not `any`
  when: (v) => v.incidentType === "vehicle",
})

Step ids are threaded the same way, so state.stepComplete("identiy") is a compile error rather than a predicate that quietly returns false forever.

Optionality is derived, never declared

There is no required: true flag and no field-type vocabulary. A field is optional exactly when its schema accepts undefinedschema.safeParse(undefined).success — and the type string the agent reads comes out of z.toJSONSchema. Two sources of truth about the same fact is a bug waiting for a deadline, and this one had an obvious single source.

Writes are never gated

This is the load-bearing one. glove_form_fill takes a patch of any field ids, not just the step the conversation is on. Each value is validated independently, so one bad answer never throws away the good ones sent alongside it. And an answer that does not currently apply is not dropped — it is held.

Liveness is recomputed from scratch on every commit rather than decided at write time and remembered. That makes it a partition, not a mutation: an answer orphaned by a correction comes back the moment the correction is corrected, because nothing was ever removed.

Two different things spelled when

Question it answersIf false
field.whenDoes this question make sense at all?Answer is held, not counted, still writable
step.whenShould we be asking about this yet?Step is not opened, fields do not ask, still writable

Applicability is about meaning; ask-order is about conversation. Keeping them separate is what lets a form with fifty conditional fields finish in six questions without any of the fields becoming unwritable.

Commit, then run

Executors hang at four points — field.onFill, step.onComplete, checkpoint.run, form.onComplete — and all four fire on a rising edge: the first commit where the condition holds having not held before. The answer is durable before any executor sees it, which is what makes at-least-once dispatch survivable. A crash mid-executor replays the hook; it never loses the answer.

Every edge bumps a per-hook counter whether or not an executor is attached, and that counter is the third segment of the idempotency key — ${instanceId}:${hookId}:${occurrence}. A retry reuses the key; a genuine second crossing gets a fresh one.

Loading is tiered like an inbox

A form is only useful if the agent knows it exists, and only affordable if knowing that costs almost nothing. One line goes into the system prompt each turn; everything else is pulled on demand.

tier 0text
[form: pi-intake] step 2/4 "Incident" · pending: Type of incident, Date of incident
later: Injuries (what hurts, treatment) · Review (confirm and sign off)

Two inclusions in there were argued for on cost and both survived. Pending labels rather than a count, because “5 fields pending” forces a tool call every turn just to learn what to ask — more expensive than the tokens it saves. And a one-line preview per remaining step, which is what makes opportunistic capture work: an agent who hears “I already have a lawyer” during step 2 can see representation is coming and grab it now.

Then we ran it against actual models

Everything above reads as correct. The surface compiled, the tests passed, the types were tight. So we built examples/forms-bench — seven scenarios, two repetitions, four cheap tool-capable models across four vendors, with real token attribution and real spend reported by the provider.

The scenarios were chosen to be uncomfortable rather than representative: a user who front-loads every answer in one message, a user whose answer is orphaned by a later correction, a malformed id mixed into an otherwise-good patch, a user who takes something back, a value over a checkpoint's cap, and a “what else do you need?” turn that tests whether the agent can see past the open step.

Total spend for the whole programme, across every round of fixes: roughly fifty cents. It found five defects that reading the code had not.

SymptomCauseCollection rate
279 unknown field ids62% differed from a real id only in case or punctuation; 17% of write calls had every field rejected69% → 85%
Half of all boolean writes sent "yes"The type string read yes / no while the error said expected boolean85% → 90%
Good answers destroyedModels wrote "" to retract, overwriting the single stored entry90% → 94%
Completed forms unreachableInstance resolution excluded complete, so no post-completion correction was possibleone model: 9/14 → 12/14
Routing silently droppedA jump back to a finished step was ignored — the one thing a routing trigger could ask for and not getrevisit shipped

The first one: ids the model cannot echo

Models do not reliably reproduce your field ids. They send Full name for fullName, staff_id for staffId. The engine rejected all of it, correctly and uselessly.

The fix is a compile-time alias index over normalised ids and labels, so all the spellings land on the same field. Any definition whose fields would collide once case and punctuation are stripped is now rejected at compile time, so resolution is never a guess. Ids that still do not resolve come back with did_you_mean suggestions rather than a bare rejection — without them the model has nothing to go on but another guess, and a wasted round trip was the most common friction on the surface.

The second one: a friendly type string

describeType rendered z.boolean() as yes / no. It reads well. It is also a lie about what the schema accepts, and models believed it: they sent the literal string "yes" for half of all writes to a boolean field, got back expected boolean, received string, and looped. Twenty one of forty eight runs had a retry loop on this single string.

It now renders true or false, and a type mismatch on a quoted number or boolean gets a hint naming the JSON shape to send. Type strings name what the schema accepts, not a friendly paraphrase of it.

The defect that changed the storage model

The third one was not a string. With one entry per field, a model writing "" to clear an answer overwrote the real one. The design said nothing is ever deleted; with a single entry per field, that was only true of applicability changes.

So storage became a per-field append-only revision log plus a cursor saying which revision is in force.

glove-memory/formstypescript
interface FieldHistory {
  /** Oldest first. Append-only — nothing is ever removed or rewritten. */
  revisions: FormEntry[];
  /** Index of the revision in force. -1 means none. */
  cursor: number;
}

A retraction is itself a revision — retracted: true with no value — so “the user took that back” and “the user changed their mind” are the same mechanism. That collapses set, retract, undo and redo into cursor arithmetic over a log that cannot lose anything, and makes every one of them reversible. Blank writes went to zero.

Four moves, one tool

All four ride on glove_form_revise behind an action parameter rather than shipping three new tools. Tool schemas are re-sent on every completion call, and the eval measured them at roughly three quarters of this surface's entire context cost. One enum on a verb the model already has is far cheaper than three more definitions — and “revise” is the honest word for all four moves anyway.

The same logic shaped the view. What undo and redo would do is surfaced as one line each at the view level, not as a flag on every field row: the agent needs to know the move exists, not to audit each field's depth on every call.

What we chose not to build

The adapter was heading toward a conformance test suite — a package that would tell an implementer whether their backend was correct. We dropped it. A conformance suite is a way of specifying behaviour by making the specification executable, and every assertion it makes is a decision taken away from the person writing the adapter.

What shipped instead is documentation with a sharp edge. Four invariants the engine actually relies on:

  1. entries appends, never replaces. A commit carries a per-field { append?, cursor? }, not a whole FieldHistory. applyEntryCommit is exported so nobody has to reimplement it.
  2. version is compare-and-set. The runner retries a conflict a few times — it relies on losing, not on winning.
  3. A commit is all-or-nothing. This is what makes commit-then-run dispatch safe.
  4. Reads hand back snapshots. If your store can return live references, clone on the way out.

And an explicit list of what is not specified: storage engine, schema, indexing, retention, how atomicity is achieved, provenance depth, multi-tenancy, encryption. An adapter's job is storage and retrieval. The engine holds every semantic — liveness, applicability, rising edges, completion — and recomputes them from whatever it is handed back.

Routing arrived last, and late

A checkpoint is a trigger: a condition over values and prior state, fired on its rising edge. It could already jump the conversation forward. Jumping back to a step that had already completed was silently dropped — which is the one thing a routing trigger could ask for and not get.

A backwards jump is now a revisit. The step reopens, its answers stay filled but come back with ask: true, and tier 0 says back at step N/M "Title" — go through it again even on an otherwise-complete form. The override is released by the next write into that step, so a jump nudges rather than pins.

Two smaller additions came with it. Executors now receive the same FormState their gates do, so a router can branch on where the conversation has been and not only on the values it holds. And an executor may return an array of effects, so one firing can stamp a derived value and move in the same breath.

a router that stamps and movestypescript
.checkpoint("triage", {
  when: (v, s) => s.stepComplete("incident") && v.severity !== undefined,
  run: ({ values, state }) => {
    if (values.severity === "minor" && !state.stepComplete("injuries")) {
      return [
        { patch: { track: "fast" } },   // stamp a derived value…
        { jump: "review" },             // …and route, in the same firing
      ];
    }
    if (values.priorClaimId) return { jump: "identity" };  // back — a revisit
  },
})

The other gap was ending a form for the right reason. { fail } records a rejection and lets the conversation carry on; { complete } claims the form succeeded. Neither fits ineligible, duplicate, or withdrawn. { terminate: reason } now stops collection outright — closes the instance with a closedReason, stops every field asking, refuses further writes, and beats a completion that would otherwise have landed on the same commit.

What generalises

The friction is almost never in the engine. Four of the five defects were in strings the model reads — an id it could not echo, a type it could not parse, a verb whose description did not say what not to do. The state machine was fine the whole time. If you are optimising an agent-facing API, the descriptions are the API.

A surface that inspects as correct can be hostile in use. Nothing on that table was found by reading the code, and several of them are obvious in hindsight. The only way to find them is to run the thing against models that do not know what you meant.

Tool schemas are the dominant context cost. Not descriptions, not results — schemas, re-sent on every completion call. That measurement is why four verbs became one enum, and it should probably change how you count the cost of “just one more tool”.

Fix the harness before you trust the number. A max_tokens of 1024 starved the reasoning models entirely — they returned finish_reason: "length" with no content and no tool calls, which is indistinguishable from a model ignoring your tools. Two graders were measuring the wrong thing: one scored retraction, the better behaviour, as failure; another asked a genuinely ambiguous question and then marked the model down for answering it a defensible way. Both are recorded in the bench README rather than quietly corrected, because a benchmark whose fixes are invisible is not evidence.

Not everything belongs to you. The adapter conformance suite would have been good engineering aimed at the wrong target. The contract needed to be small enough that somebody could implement it over a store we have never heard of, and documentation does that where a test suite does not.

Still open

Switching to a different form definition mid-conversation has no effect type. A trigger can terminate the form it is in and an agent can start another, but there is no single move that hands the collected values across — and the instance and value semantics of that move are a product decision rather than an engineering one. It is deliberately unbuilt.

Forms ships as glove-memory/forms with an in-process reference adapter, seven agent tools plus a read-only eighth, and the eval harness under examples/forms-bench — which is worth running against your own forms, not just ours. The memory docs carry the full API.