You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
feat(spec): declare publicPicker on FormFieldSchema (#7467)
The REST public-lookup route (GET /forms/:slug/lookup/:field) has always
gated the anonymous picker on a publicPicker block that no schema declared:
FormFieldSchema is strict (ADR-0089 D3a), so every authoring path refused a
form carrying one and the capability was unreachable — ADR-0049's
'declared ≠ enforced' in the mirror direction. Per the maintainer ruling on
the card (declare, option 1), FormFieldSchema now carries an optional
publicPicker block mirroring exactly the route's four reads:
- displayFields (≤5, the route's projection cap; omitted → ['name'])
- maxResults (int 1..50, encoding the route's hard ceiling; default 20)
- filter (ViewFilterRuleSchema[], the dialect the route composes)
- object (referenced-object override)
An unknown subkey stays a loud unrecognized_keys error; picker.sort — a
fifth route read outside the ruling's enumeration — stays deliberately
undeclared and pinned as such (follow-up filed from #7467).
Also: ADR-0049 liveness ledger (view.json sections row + viewschema audit),
the owner_id pin rebuilt through the real schema, a stored-row e2e proving
a spec-valid form with a picker gets a real lookup answer, docs for the
lookup route, regen artifacts, minor changeset.
Closes#7467
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CnMk7vfrt2zW7xvwLS3JDf
|**reference**|`string`| optional | Target object name for lookup/master_detail fields |
142
+
|**publicPicker**|`{ displayFields?: string[]; maxResults?: integer; filter?: object[]; object?: string }`| optional | Opt this field into the anonymous public-form lookup picker (GET /forms/:slug/lookup/:field). Without it the route answers 403 LOOKUP_NOT_PUBLIC and the field is stripped from the rendered public form. |
142
143
|**maxLength**|`number`| optional | Maximum character length (for text/textarea/email/url/phone) |
143
144
|**minLength**|`number`| optional | Minimum character length |
144
145
|**min**|`number`| optional | Minimum value (for number/currency/percent/slider) |
Public-lookup opt-in: enables GET /forms/:slug/lookup/:field for this field on an anonymous public form (without it the route answers 403 LOOKUP_NOT_PUBLIC).
226
+
227
+
### Properties
228
+
229
+
| Property | Type | Required | Description |
230
+
| :--- | :--- | :--- | :--- |
231
+
|**displayFields**|`string[]`| optional | Fields projected into each picker result (with `id`); the visitor's search matches `contains` on the first entry. At most 5 (the route projects no more); omitted → ['name']. |
232
+
|**maxResults**|`integer`| optional | Maximum rows a lookup returns (default 20, hard ceiling 50 — the route clamps; anonymous visitors cannot paginate past it). |
233
+
|**filter**|`{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'starts_with' \| … +14 more>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]`| optional | Static pre-filter rows ANDed ahead of the visitor's search (e.g. only active records are searchable). Same `{ field, operator, value }` dialect as list-view filters. |
234
+
|**object**|`string`| optional | Referenced-object override for the picker search; omitted → resolved from the field definition (`referenceTo`). |
|`displayFields`| Fields projected into each result row (plus `id`); the visitor's `q` is `contains`-matched against the **first** entry. At most 5; omitted → `['name']`. |
256
+
|`maxResults`| Rows per request, integer 1–50 (default 20). 50 is a hard server ceiling; there is **no pagination** on this surface (`offset` is pinned to 0), so a leaked endpoint cannot enumerate the table. |
257
+
|`filter`| Static pre-filter rows (same `{ field, operator, value }` dialect as list-view filters), ANDed ahead of the visitor's search. |
258
+
|`object`| The object to search; omit to let the server resolve it from the field definition. |
259
+
260
+
The block admits exactly what the route enforces — an unknown subkey, a 6th
261
+
display field, or `maxResults: 51` is a **parse error at authoring time**, not
|`400 INVALID_REQUEST`| missing / blank slug or field |
278
+
|`403 LOOKUP_NOT_PUBLIC`| the field has no `publicPicker` block — the deliberate loud default (#3022); also any server-managed anchor (`owner_id`, `organization_id`, …), which never gets a picker even if one is declared |
279
+
|`404 FORM_NOT_FOUND`| slug not registered on any `sharing.allowAnonymous: true` view |
280
+
|`500 LOOKUP_TARGET_MISSING`| the referenced object could not be resolved — declare `publicPicker.object`|
281
+
235
282
### Auth model
236
283
237
-
-Neither route calls `enforceAuth`, so they work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17).
238
-
- The execution context handed to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin honors `publicFormGrant` first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a `guest_portal` profile. `permissions: ['guest_portal']` is retained for back-compat.
284
+
-None of the three routes calls `enforceAuth`, so they work under the always-on anonymous-deny default (there is no `requireAuth` knob to configure since v17).
285
+
- The execution context the **submit** route hands to ObjectQL is `{ publicFormGrant: { object }, permissions: ['guest_portal'], anonymous: true }` with no `userId`. The Security plugin honors `publicFormGrant` first — a create + read-back grant scoped to exactly the declared object — so authorization holds even without a `guest_portal` profile. `permissions: ['guest_portal']` is retained for back-compat. The **lookup** route's search context is `{ permissions: ['guest_portal'], anonymous: true }` — no `publicFormGrant` (it reads the picker's target object, not the form's), which is why its result set is bounded by the picker declaration instead.
239
286
- No CSRF or auth header is needed; embed the form on any domain.
-**Added post-audit (#7467, 2026-08-11)**: FormField `publicPicker{displayFields,maxResults,filter,object}` — live, consumer is `framework/packages/rest/src/rest-server.ts``GET /forms/:slug/lookup/:field` (projection, hard cap 50, pre-filter, object override). The key had been enforced there but declared in no schema (the mirror of this audit's usual finding: enforced, never declarable), so no spec-valid form could reach it; declared per maintainer ruling on #7467. The route's fifth read, `publicPicker.sort`, stays deliberately undeclared (outside the ruling's enumeration; follow-up filed from #7467).
18
19
19
20
## DEAD — no consumer anywhere
20
21
`userActions.buttons`, `addRecord.mode`, `addRecord.formView`, `sharing.lockedBy`, list-level `responsive`, list-level `performance`, FormView `submitBehavior{thankYou,redirect,continue,nextRecord}`, FormView `defaultSort`, FormView `sharing` (renderer side), `ViewData` providers `api` & `schema` in the **list** path, `tab.order` (not used for sorting).
0 commit comments