# Where the web UI disagreed with the server Thirteen places where the Add-table builder and the append-columns panel offered, promised or accepted something the backend does not do. All thirteen are fixed in the `web` crate; the server is untouched. Ordered by severity. Severity is what the gap costs the user: - **Blocking** — the UI leads you to a definition the server refuses outright. - **Wrong** — the UI states something untrue about the table you are creating. - **Missing** — a real backend capability the UI gives you no way to reach. - **Misleading** — accurate enough to act on, wrong enough to confuse. `cargo test -p web`: **158 passed**, up from 134. Clippy clean for every file touched. --- ## 1. Indexing a FKlink — blocking **Was:** the column panel offered `Indexing: yes/no` for a link like any other column, and the column list gave it an index toggle. **Reality:** a link is a foreign key, and the server builds an index for every one of them as it creates the table (`server/src/table_definition/managed_table.rs:361`). Naming a link in `indexes` is not redundant — it is refused: ``` Link 'billing_customer' is indexed automatically ``` `post_table_definition.rs:514`, and the same rule on the append path at `add_table_columns.rs:322`. So the whole table failed to create, after the Create button, for a checkbox the UI itself offered. **Now:** `ColumnDraft::is_indexable` is false for a link, the Indexing field is replaced by *"Indexed automatically — a link is a foreign key, and the server indexes every one of them"*, the column list reports `indexed automatically` with no toggle, `selected_index_names` can never name a link, and a rebuilt or tampered post is refused here with the server's own reason instead of being forwarded. Both screens. ## 2. Global tables and the columns that post to a profile's books — blocking **Was:** the shared scope offered ACCOUNTING, ACCOUNTING_TRANSFER and the quantity-ledger switch exactly as a profile scope does. **Reality:** `post_table_definition.rs:280` — *"Global tables cannot use accounting or quantity-ledger columns"*. A shared table belongs to every profile at once, so there is no one set of books for it to post to. **Now:** the two types are not offered in the shared scope, the quantity-ledger field is not rendered there, and both are refused with the reason. Switching an existing draft to the shared scope reports it *at the switch* rather than at save — the picker going quiet is not an explanation on its own. ## 3. Table names could be 63 characters — blocking **Was:** `validate_identifier(…, "Table name")`, the plain 63-character identifier rule. **Reality:** a table name is a prefix that longer identifiers are built from — `idx___fk` has to fit in 63 too — so the server's limit is **38** (`catalog/object_naming.rs:35`). Anything from 39 to 63 characters passed the browser and failed at the backend. **Now:** `validate_table_name`, with the limit derived from the same arithmetic and the same `common` constant the server reads, so a system column added there moves it here too. The test asserts it lands on 38. ## 4. Reserved table names — blocking **Was:** nothing stopped a table called `accounts` or `general_ledger`. **Reality:** every profile is given `general_ledger`, `journal_lines`, `quantity_ledger`, `accounts` and `custom_exchange_rates` when it is created; `post_table_definition.rs:221` refuses the names. **Now:** refused in the builder, naming the table and why. ## 5. `required` was unreachable — missing **Was:** `proto_columns` hardcoded `required: false`. No control anywhere. **Reality:** `required` is a live column property. The server stores it and enforces it on every row written (`table_validation/runtime.rs:75`) — a row that omits a required column is rejected. The web UI could not produce a required column at all, and could not tell you a column was one. **Now:** a Required field on both column panels, carried through the form, listed as a `required` tag, shown in the preview and sent on the request. ## 6. The append screen could not link to a shared table — missing **Was:** its link-target picker read `page.tables`, which for a profile scope is that profile's own tables. **Reality:** the server resolves a link against the profile's tables **or** any global one (`post_table_definition.rs:684`). Shared tables — currencies, code lists — were simply absent from the picker with no way to reach them. **Now:** link targets are their own list, built by `table_scope::linkable_tables` (the same helper the builder uses), while the browse list stays the scope's own tables. This is the same class of bug as the "No table chosen" one fixed earlier in this session: one question, three answers. ## 7. A link could point at the table being created — blocking **Was:** the builder filtered its own name out of the picker, and that was all. The table-name field has no `hx-trigger` by design, so adding `link(invoice)` and *then* naming the table `invoice` sailed through. **Reality:** `post_table_definition.rs:400` — *"Link 'x' cannot point at the table being created"*. **Now:** a rule in `validate`, not just a filter on a list, so the order the fields were filled in cannot get round it. ## 8. `link(accounts)` was refused only by the server — blocking **Was:** the builder's loader dropped `accounts` from the picker, but nothing validated a draft that carried the link anyway. **Reality:** *"The account relationship is built into ACCOUNTING and cannot be declared as a link"* (`post_table_definition.rs:406`). **Now:** a rule in `validate`, shared by both screens. ## 9. A declared column could collide with a generated one — blocking **Was:** duplicate names were checked against the declared columns only. **Reality:** the server checks against every name the table will hold, generated ones included — *"Column 'debit' conflicts with a column generated for ACCOUNTING"* (`models.rs:450`). Declaring `debit` beside an ACCOUNTING row was accepted by the builder and refused by the backend. **Now:** `ColumnDraft::claimed_names` is the set both `add` and `validate` check, in both directions — the declared column first or the definition row first. Companions named after their own column (`work_phone_ext`) still never collide. ## 10. Rows could not be identified by a generated column — missing **Was:** row-display candidates were the declared, non-compound columns. **Reality:** the server accepts any of the table's real column names, generated ones included (`post_table_definition.rs:482`), and even maps a display column of `accounting` onto whatever its `name` companion ended up called. A table whose only readable column is generated — an ACCOUNTING row's `name` — had nothing to be shown by. **Now:** every column the table will really hold is a candidate, under its aliased name, and the definition row itself still is not one. ## 11. The preview omitted `row_revision` — wrong **Was:** the preview claims to be "the schema as it will exist" and listed `id`, `deleted`, … `created_at`. **Reality:** every managed table also carries `row_revision` (`common/src/system_column.rs:20`). It was also missing from the names an alias may not take, so `row_revision` could be asked for as a generated column's alias and refused by the server. **Now:** in the preview and in the reserved set. ## 12. "already exists in profile ``" — misleading **Was:** the duplicate-table-name error always named a profile. In the shared scope there is no profile, so it rendered an empty pair of backticks. **Now:** scope-aware. The shared scope says the name has to be free in every profile; a profile scope says the name may also be taken by a shared table, which is what the check really covers. ## 13. Index and options were reported twice — misleading **Was:** `option_label()` included `indexed`, and the column list rendered it as a tag *beside* the Indexed column that said the same thing. **Now:** the list's Indexed column is the only place index state is reported; the options column carries required, quantity ledger and currency. --- ## Deliberately left alone - **The link's `_version` column.** Every link creates one (`managed_table.rs:324`), and the preview does not show it. It is an internal column — `common/src/system_column.rs` says so — never exposed by the data API, so listing it would be noise rather than honesty. - **`account_id` shown as well as `account`.** Both are real: one is the physical column, one is the API name. The preview already explains the relationship. --- ## Suggested server changes None of these are needed for the fixes above — the web crate works around each one — but each is a place where a client can only be correct by knowing something the backend never tells it. 1. **`ListColumnTypes` should say which types a shared table may not use.** A `profile_only: bool` on `ColumnType` would let a picker be right without naming ACCOUNTING and ACCOUNTING_TRANSFER in client code. Today `web/src/schema/mod.rs` has to hardcode the pair (`PROFILE_ONLY_TYPES`), and a third type with the same restriction would be silently offered. 2. **`ListColumnTypes` should say a link is indexed automatically.** Same shape of problem as (1): the rule is real and client-visible, but the only way to know it is to have read `managed_table.rs`. An `indexed_automatically: bool` would make finding #1 impossible to reintroduce in any client. 3. **The reserved table names should be reportable.** `general_ledger`, `journal_lines`, `quantity_ledger`, `accounts` and `custom_exchange_rates` are constants of three separate server modules. Every client that wants to refuse them early has to copy the list, and will drift when the server adds one. They belong on a capability or catalog response. 4. **`MAX_TABLE_NAME_LENGTH` should be reportable too**, for the same reason. The web crate now derives it from the same `common` constant the server does, which holds only because both compile the same source file — a coincidence of layout rather than a contract. 5. **Projection columns are unreachable from any client.** `post_table_definition` handles a `from(link_column.source_column)` field type (`link_column_projection.rs:30`), but `COLUMN_TYPE_CATALOG` does not report it and `ColumnTypeSpelling` has no variant for it — only `Bare`, `Decimal` and `Link`. So a feature that exists in the backend cannot be offered by the web UI or the TUI. Either add the spelling and catalog entry, or the code is dead. 6. **The append path checks the global quantity-ledger rule only at execute time.** `add_table_columns.rs:238` runs after the table is resolved, so a client has to work out for itself whether the table it is appending to is global. The web crate now infers it from the profile tree; reporting it on the table would be sturdier.