feat(formdesigner): create forms without an LLM via POST /forms - #1070
feat(formdesigner): create forms without an LLM via POST /forms#1070jelitox wants to merge 1 commit into
Conversation
`POST /api/v1/forms` was LLM-only: it required a `prompt` and returned
503 without a client. A form builder therefore had no way to get a blank
canvas to work on — PUT/PATCH/operations all 404 on an unknown form_id,
leaving only `from-db` or `clone` of an existing form.
Make the endpoint dual-mode, selected by the presence of the `prompt`
key:
- with `prompt` — unchanged CreateFormTool path (503 without a client,
400 on an empty prompt, response `{form_id, title, url}`).
- without `prompt` — new `_create_blank_form`: builds the FormSchema
straight from the body with no LLM, returns 201 with the full schema.
An empty body is valid, so a "New form" button can POST `{}` and then
add controls with `PATCH /forms/{form_id}/operations`.
Blank forms are seeded with one empty section (`section_1`) so
`add_field` has a target; `"sections": []` opts out. `form_id` is taken
from the body when it matches FORM_ID_RE (409 when taken) or derived
from `title` with a random suffix on collision, so clicking "New"
repeatedly never fails. `version`, `published_version` and `tenant` are
handler-controlled and ignored from the body — tenant always comes from
the session.
Duplicate detection has to consult storage, not just memory:
`FormRegistry.get()` is memory-only, the cache is hydrated per tenant,
and `PostgresFormStorage.save()` upserts, so a memory-only check would
let a "new" form silently overwrite a persisted one belonging to a
non-hydrated tenant. Adds the opt-in
`FormRegistry.contains(..., include_storage=True)` for that, treating a
storage error as "cannot prove absence". Also verifies object identity
after `register(overwrite=False)` — it is a silent no-op on a taken id,
so the loser of a race would otherwise get a 201 for a form that was
never stored.
Developed By: Javier León (With SDD) <jleon@trocglobal.com>
Paused pending design decisionsHolding this PR while two things land: FEAT-388 ( Recording what the investigation turned up, since it is input for that brainstorm. The blank-form affordance in this PR is wrongReview feedback, accepted: a blank canvas must not be a server-side object. Opening "New form" in Word does not write That makes the seeded empty section and the empty-body affordance the parts to drop. The underlying capability — persist a complete, client-built schema with no LLM — is still needed, and FEAT-388 already specifies it at the tool layer (
|
Problem
POST /api/v1/formswas LLM-only: it required apromptin the body andreturned
503 No LLM client configured for form creationwithout a client.That left a form-builder UI with no way to get a blank canvas to work on.
PUT /forms/{id},PATCH /forms/{id}andPATCH /forms/{id}/operationsall404on an unknownform_id, so the only LLM-free ways to bring a form intoexistence were
POST /forms/from-db(NetworkNinja definition) or cloning anexisting one. "New form → blank → drag controls" was not expressible.
Change
POST /api/v1/formsis now dual-mode, selected by the presence of thepromptkey:{"prompt": "..."}CreateFormTool(LLM) — unchanged200 {form_id, title, url}{}_create_blank_form— no LLM201+ fullFormSchemaDesign decisions
section_1,because
add_fieldrequires an existingsection_id— without it the canvaswould need an
add_sectionround trip first. Pass"sections": []for none.form_id. Explicit when supplied and matchingFORM_ID_RE(
400on spaces,/,.., control characters;409when taken). Otherwisederived from
title, with a random hex suffix on collision — so clicking"New" repeatedly never fails with a
409.versionis always"1.0",published_versionalwaysNone(publishing staysPOST /forms/{id}/publish),and
tenantalways comes from the session, never the body — a caller cannotwrite into another tenant. Every other
FormSchemafield passes through.{"prompt": ""}keeps itshistorical
400 prompt is requiredrather than silently creating a blank form.Storage-aware duplicate detection
FormRegistry.get()is memory-only, the in-memory cache is hydrated pertenant, and
PostgresFormStorage.save()upserts (ON CONFLICT ... DO UPDATE).A memory-only existence check would therefore let a "new" form silently
overwrite a persisted form belonging to a tenant that has not been hydrated
yet — data loss.
This PR adds the opt-in
FormRegistry.contains(..., include_storage=True),which probes storage when the form is not in memory and treats a storage error
as "cannot prove absence" (refusing to create beats overwriting).
It also verifies object identity after
register(overwrite=False): that call isa silent no-op on a taken id, so the loser of a concurrent create would
otherwise receive a
201describing a form that was never stored, and itssubsequent
/operationscalls would edit the winner's form.Testing
tests/unit/api/test_create_blank_form.py, including the fullround trip (create blank → add 2 fields + 1 section via
/operations→ assertpersistence), a mocked storage backend for the upsert-overwrite case, and a
monkeypatch-simulated lost race.verified against the same base with the change stashed (1227 passed, same 14).
They are stale assertions unrelated to this change, e.g.
test_form_controls_endpointdoes not know about thesupported_effects/supported_operations/supported_operatorskeys thatTASK-1529 added on 2026-06-12, and several count
FieldTypeagainst anoutdated total.
ruff checkclean on all touched files.Reviewed against an independent adversarial pass
An independent review raised six findings. Four are fixed here (the
storage-overwrite bug, the create race, prompt truthiness, and a
$-vs-\Zregex anchor that let a trailing newline through). Two are real but
pre-existing and out of scope, flagged for a follow-up decision:
PATCH /forms/{id}/operationsdoes not pass through RBAC.handle_operationsnever calls_rbac_shadow_gate, whilePUT/PATCHenforce
update_form/patch_form. Pre-existing since FEAT-152 and currentlymoot (RBAC defaults to shadow mode, log-only), but this PR makes
/operationsthe recommended builder path, so the gap now matters more.Fixing it is not mechanical:
handle_operationsis a module-level functionwith no handler instance.
FormRegistry.register()catches storage exceptions, logs, and returns normally — so a
201does notprove the form survived a restart. This is shared with
PUT/PATCH//operations; changing it altersregister()'s contract for every caller,so this PR stays consistent with them rather than diverging.
Notes
No migration and no breaking change: the
promptpath keeps its exact statuscodes and response shape, and
contains()gains a defaulted keyword argument.Developed with Spec Driven Development