# Writing a form to a tenant

*Open when a draft is ready to go to a real `Forms` row, or when the request changes a form's name, visibility, priority, type or access lists.*

## Since 2.1 the form is columns, not a blob

The live form is first-class columns on the `Forms` row:

| Column | Holds |
|---|---|
| `schema` | `{ components: [...], connections: [...], overrides: [...] }` — the element list, the wiring, and a child form's diff |
| `settings` | the form settings object (`columnsNumber`, `breakpoints`, `padding`, `isFormBuilderV2`, …) |
| `mobileSchema` | the mobile presentation, or `null` |
| `code`, `codeArtifact`, `codeInheritance`, `inheritedCode` | the code-form and what it inherits |
| `parent`, `parentVersion`, `settingsOverrides`, `connectionOverrides`, `localizationOverrides`, `sharedModuleRefs` | inheritance |
| `status` | `draft` \| `editing` \| `published` |
| `variables`, `localizations` | **related rows**, not JSON — a separate write each |

`data` is the pre-2.1 blob and is **residue**. Measured on the dev tenant 2026-09-21, 217 rows: 78 live in the `schema` column, 3 stale rows still in `data.formSchema`, 86 are Suppa 1.0 (`data.formShema`), 35 are empty, and 15 carry both a column and a blob. On every migrated row `data.formSchema` is an empty array. **A write to `data.formSchema` returns 200 and changes nothing the builder renders.**

## The write replaces whatever column it names

```
POST /api/core/data/Forms/update
{ "fields": { "schema": { …whole object… } },
  "conditions": { "operator": "and", "filters": [{ "field": "id", "comparator": "=", "value": 42 }] } }
```

A column is stored whole; every key you omit inside it is gone. So read the row first — `POST /api/core/data/Forms/select`, same `conditions`, projection including **`schema: true`** — and spread the existing object under your changes. Select answers in three envelopes depending on the tenant (bare array, `{items:[…]}`, `{data:[…]}`); an unparseable one leaves you with no row, and merging over `{}` drops `connections` and `overrides` on a call that returns **200**.

`settings.isFormBuilderV2: true` is the host's routing discriminator (`FormHost` vs the legacy `EntityCard`). Losing it in the merge makes a 2.x form render as a v1 record card.

### …and it leaves no change-set

The builder does not save through this endpoint. Its own save-draft is `PUT /forms/:id/schema`, and
**every such call that actually changes something writes a change-set row** — an RFC-6902 patch with
its author, timestamp and `affectedComponents`, served back by
`GET /api/core/forms/{formId}/change-sets` and rendered as the History panel's *View changes*. A
write through the generic data endpoint bypasses all of that, so:

- the edit **does not appear in History** — there is no row to show;
- worse, it breaks the invariant the platform verified on live data (*"реплей уперед від порожнього
  документа дає точний стан після кожного сейву… форма 233, 21 change-set: відтворена schema
  побайтово дорівнює GET /schema"*). After an out-of-band write the replay no longer reconstructs
  the real form, so **later** change-set diffs, and the unversioned-column reconstruction that feeds
  the Publish gate and modal, are all computed against a baseline that silently drifted. The
  platform names this exact case: *"Незалогований запис у форму зсуває реконструкцію"*.

Nothing is corrupted and nothing is lost — but say so when you write this way, and prefer the
builder for an edit somebody will later need to read the history of.

## `status` is not the publish switch

Nothing in the platform's publication documentation gives `Forms.status` a role. Whether a form has
unpublished changes is a **computed diff** — the draft against the last `FormVersions` snapshot
(`computeFormDiff`, the same one the Publish modal renders; *"той самий diff гейтить кнопку Publish
(`formPublishGate.hasUnpublishedChanges`), щоб гейт і модалка не розходились"*) — and which version
the runtime serves is decided server-side from `versionMode` on `POST /core/forms/resolve`.
`status` is one more unversioned top-level column, replayed by history beside `name` and `alias`.
Our measurement (12 `editing` rows, 11 with a drifted schema) is a correlation, not a mechanism.
That is the real reason a draft write leaves `status` alone: writing it would change nothing and
only muddy the history.

## `FormVersions` is the undo — for forms that are published, and for eight keys only

Every publish writes a snapshot row: `snapshot` (`{schema, mobileSchema, settings, variables, code, codeArtifact(s), codeInheritance, inheritedCode, localizations, rawLayer, sharedModuleRefs, chainSharedModuleRefs, globalLocalizationKeys}`), `versionNumber`, `status`, `isDefault`, `publishedAt`/`publishedBy`. On dev: 1,588 rows over 103 forms since 2026-06-29, every row `published`, exactly one `isDefault` per form. So a bad write on a published form is recoverable — restore the version. **A restore publishes nothing**: it puts the old content back on the draft, everyone keeps seeing the current published version until someone presses Publish, and that then mints the *next* version number. Version numbers only ever go forward.

**Verified against the backend by the platform itself (2026-07-31, six recent `FormVersions`): the
snapshot holds `schema`, `mobileSchema`, `settings`, `variables`, `localizations`,
`globalLocalizationKeys`, `codeArtifact`, `sharedModuleRefs` — and nothing else.** `type`,
`isPublic`, `priority` and `condition` are not in it. They are not versioned at all, so restoring a
version cannot undo them.

Three limits, all measured:

- **A plain row update does not create a version.** 16 of the 55 forms updated in the last week have zero version rows. Versions come from the builder's publish action, so a write through the data endpoint most likely leaves no restore point — and the snapshot you would restore is the last *published* one, not the state before your write.
- **A form that was never published has no history at all.** Check before relying on it.
- **The unversioned columns have no draft stage either.** `saveDraft` writes `type`, `isPublic`,
  `priority`, `condition` and the access lists straight into the `Forms` row, so they take effect
  *"одразу після Save draft, без Publish — опублікована версія на них не впливає"*. There is no
  publish step to gate them and no version to restore. The same is true of an entity table's
  columns, which live in a system `Layout` rather than the schema. Treat any of these as a
  production change and confirm it as one.

## Inheritance: a child form stores only its diff

`parent` + `parentVersion` pin a version of the parent. The child's own `schema.components` is not the rendered form; what it inherits is not in the array. Its changes live as `{ op, target, values }` entries in `schema.overrides` (target = element `id`), `settingsOverrides` (target = a settings key), `connectionOverrides` (target = a connection `id`, e.g. `elc-c21`). 16 of 217 dev rows are children, including the most-edited workspaces.

Writing `schema` wholesale on a child drops the override layer and silently detaches the form from its parent. Connection ids are load-bearing — never regenerate them.

**The op vocabulary differs per collection.** Components take `modify` / `remove` only; settings
also take `add`. A `modify` on a settings key **replaces the value whole** — it does not merge, so
an override of one sub-key must carry the whole object. Variables are plain-merged **by name**, and
an inherited variable **cannot be removed** by a child — the contract cannot express it. Localization
overrides are their own op array (`localizationOverrides`), where `modify.values` is a per-language
delta and `null` clears a language. `connectionOverrides` is **ours, measured off dev rows** — it is
not part of the documented inheritance contract, which names components, settings, variables and
localizations only.

**Identity is locked on an inherited element.** `name` and `type` cannot be overridden (the options
panel disables both rows), and `id` cannot change — the merge matches an override to its parent
element *by id*, and a retyped inherited element produces a hard `element_type_changed` conflict
that blocks the child's publish. An override may change label, props, layout, dataSource and
localization; never identity. Deleting an inherited element **is** legal (a `remove` override, with
ghost/restore in Layers).

**A child with unresolved conflicts cannot publish.** When the parent moves, a rebase raises
conflicts; the hard ones (an inherited element deleted upstream, a variable deleted, a localization
key deleted, a settings key deleted, an element retyped) need a human decision, and
`POST /forms/:id/publish` answers **409 `{ openIssues }`** while any is open. Soft and automatic
conflicts never block. So "the write succeeded" on a child form is not "the child can ship".

**Preconditions for attaching a parent**, enforced by the backend and easy to trip when writing the
row by hand: the child and parent must share `viewType`; no self-reference and no cycles; chain
depth ≤ `MAX_DEPTH` (20) — though the UI is designed for a single level, base → child; one parent
only.

## Form kinds — the top-level `type` column

`create` | `update` | `custom` | `workspace` | `dashboard` | `workspaceModule`. `custom` is the only kind allowed with **no bound entity**. Legacy v1 values (`createForm`, `customForm`, `table`, …) still sit on old rows: echo them back, never write one.

`custom` and `workspace` are **submitless** — the runtime suppresses the submit button and autosave, so a `purpose: 'submit'` button and the submit settings (`showSubmitButton`, `isTransactionMode`, `showSubmitNotification`, `closeOnSubmit`) do nothing. One exception: an entity-less `custom` form is the fillable kind, and there are **two** routes for it, which are not the same thing.

| Route | Who | Footer |
|---|---|---|
| `/form/:idOrName` | a **signed-in** user; the form is resolved by id or by its unique name | `settings.showSubmitButton: true` is the opt-in, and without it the form opens **with no submit button at all** — not an error, just a default-off opt-in. No consent checkbox, no honeypot, no rate limit: those exist only on the anonymous route. An **entity-bound** form on this route renders no submit UI either — the route is for entity-less forms. |
| `/public/form/:slug` | **anonymous**, through a link published from the builder toolbar | the same `showSubmitButton` opt-in, plus the platform's privacy notice and consent checkbox in the footer. Serves the **last published version only** — a link to a never-published form opens as unavailable. |

On either route a hand-authored CTA renders outside the platform footer, so on the anonymous one it bypasses the consent block. Set the flag instead.

## Metadata is a SECOND write

Same endpoint, different fields: `name`, `isPublic`, `priority`, `type`, `accessUsers`, `accessGroups`, `accessDepartments`, each relation list shaped `[{ id }]`, never bare ids. Forbidden here: **`entity`, `alias`, `id`, `lockedBy`** — the first two invalidate the schema and break navigation links. `lockedBy`/`lockedAt` mean someone has the form open in the builder; do not write over a locked form.

**A lock can outlive its holder, and "ask them to close it" is then useless advice.** There is no
heartbeat and no auto-reclaim by design: *"No heartbeat means a killed tab, a crash, or lost power
leaves the row locked forever; takeover is the only recovery path, and it is always available."* So
read `lockedAt` as well as `lockedBy` and report the lock's **age** — the platform's own takeover
modal does exactly that, *"the modal states the age so an obviously dead lock is easy to judge"* —
and name the real remedy: a human opens the form and presses **Take over** in the lock banner,
which always asks, whatever the age, and warns that the holder's unsaved changes are lost.

`isPublic` is *tenant* visibility: `false` restricts the form to the access lists, `true` lifts that for signed-in users. It creates no anonymous URL; the no-login `/public/form/:slug` link is a builder-toolbar feature. A `type` change needs an explicit confirmation before you send it.

Two further columns exist and are panel-only — leave them to the human and say so: the record **display filter** `condition` (which form opens for which record; not an element's `visible` group) and `requiredForms` (a many-to-many to `Forms` naming this form's dependencies — the forms it embeds or opens — which the backend returns already expanded in `resolve`, so a card that embeds another form needs one round-trip instead of two; the builder keeps it in step with the schema, and hand-editing it is how the two drift).

## When the form needs schema that does not exist yet

Order, and it is **not transactional**: every `create-entity` first, then all `add-fields`, then `update-field`, then enum values last. All entities before any field, because `add-fields` resolves a relation target against a *live* entity. Enums last, because a per-entity enum's rows carry the owner entity's id.

A failure mid-sequence leaves everything already written standing. Name the entities and fields that landed, then retry — creates are existence-probed and `update-field` is a changed-keys diff.

Three things a plan may carry that **nothing on this path applies**: entity metadata (title, comment, icon, representative field, moving an entity into an application), tabular parts, and `application` on a create — that one validates, then evaporates at the wire, and the entity lands in Shared. Say so; do not report them done.

## What has not been verified

No 2.x form has been written through this path from here. The column shapes above are read from live rows; the *write* side — that `POST /api/core/data/Forms/update` with `fields.schema` is accepted, that the builder picks it up without a publish, that `variables`/`localizations` can be written as related rows — is inferred, not observed. Say which of these you relied on when you report a form written.
