Skip to content

publicPicker is enforced by the REST lookup route but declared nowhere in packages/spec — no saved form can ever enable it #7467

Description

@os-zhuang

Found while implementing #7134 (the groups → sections save-path fold). Filed, not fixed: closing it means deciding where the key belongs in packages/spec, which is a spec-acceptance change and out of #7134's lane.

The gap

GET /forms/:slug/lookup/:field in packages/rest/src/rest-server.ts gates the public lookup on an opt-in publicPicker block on the field's declaration in the form spec. Without one it answers 403 LOOKUP_NOT_PUBLIC — deliberately loud rather than silent (#3022).

That key is declared in no schema:

$ grep -rn "publicPicker" packages/spec/src/
(no hits)

FormFieldSchema is strict, and ADR-0089 D3a made an undeclared key a loud parse error rather than a silent drop. So a form carrying a publicPicker is refused by ViewMetadataSchema:

Unrecognized key(s) on this view/page schema: publicPicker. Before ADR-0089 D3a these were dropped silently, shipping inert metadata; a mis-layered or stale key is now a loud parse error.

Measured on origin/main @ 08363a09f, through the real ViewMetadataSchema.safeParse, on the ViewItem branch:

field declaration parse
{ field: 'owner', publicPicker: { displayFields: ['name'] } } rejected — unrecognized_keys
{ field: 'owner' } accepted

saveMetaItem validates against that same schema, so a form declaring a picker is a 422 and never becomes a sys_metadata row. Code-authored forms hit the same wall — FormViewSchema.parse is the same door.

Consequence

The public-lookup capability is unreachable through any authoring path this repo accepts. The route, its displayFields projection, its maxResults cap (hard ceiling 50), its publicPicker.filter pre-filter and its publicPicker.object override are all live code that no spec-valid form can turn on. That is Prime Directive #10's "declared ≠ enforced" in the mirror direction: enforced, never declarable.

It is also why #7134 could close only two of the three degradations #6926 listed. The third — GET /forms/:slug/lookup/:field answering 403 for every field — does not clear on the stored-row path, and not because the fold missed it: the fold does reach the route's sections walk (the two sibling routes are the same walk and they changed), but there is no picker in those sections to find. #7134 pins that boundary explicitly in packages/rest/src/public-form-routes.stored-row.test.ts (BOUNDARY: the lookup route is STILL 403 — for a different reason), and that assertion is written to go red the day this is fixed, so whoever lands it is told to revisit.

Decision needed before implementing

Two directions, and they are opposite — this needs a ruling, not a guess:

  1. Declare it. Add a publicPicker block to FormFieldSchema (displayFields, maxResults, filter, object) matching what the route already reads. Makes the capability reachable; adds an authorable property to the ADR-0049 liveness surface.
  2. Retire it. Treat an undeclarable opt-in as a capability nobody can use and remove the route's picker branch under the spec-property-retirement playbook.

Note the existing pin in packages/rest/src/public-form-routes.test.ts ('refuses a publicPicker declared on owner_id') builds its fixture as a raw object that never passes through the schema, which is why the gap was invisible from that side.

Related: #7134, #6926, #3022, #6601, #6920, ADR-0089, ADR-0049, ADR-0106.

Activity

  1. os-zhuang commented on Aug 10, 2026

    @os-zhuang
    ContributorAuthor

    Triage: needs-user-decision + domain:spec; domain:metadata removed (mislabel correction, single-producer lane).

    本评论来自分诊座位 Routine(#5474 试点),不构成认领。


    Generated by Claude Code

  2. self-assigned this
    on Aug 11, 2026
  3. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    Maintainer ruling (2026-08-10, spec-lane PM session), verbatim and untranslated: 「宣告」 — option 1 of the card's fork: declare publicPicker in packages/spec. The retirement direction is closed.

    needs-user-decision → pm:queue executed on the ruling (kept domain:spec).

    CLAIM — spec-lane PM seat (#6017), session session_01PiRUoQkTSBBmpyXBY3cVn2. Branch: claude/issue-7467-declare-public-picker. Dispatching a cloud dev session (model: Fable — judged design-heavy: a new authorable block on the public acceptance surface, gating PUBLIC unauthenticated lookups, plus two cross-package pin flips the card names).

    Direction bounds recorded at dispatch:


    Generated by Claude Code

  4. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    DEV CLAIM — dev seat for this card, dispatched by the spec-lane PM (#6017, session_01PiRUoQkTSBBmpyXBY3cVn2).

    • Session: session_01CnMk7vfrt2zW7xvwLS3JDf
    • Branch: claude/issue-7467-declare-public-picker
    • Scope, per the maintainer ruling 「宣告」: declare publicPicker on FormFieldSchema in packages/spec (displayFields, maxResults ceiling 50, filter, object — mirroring exactly what the REST lookup route reads), ADR-0049 ledger entries, flip the two named pin tests, os-regen, minor changeset, draft PR. Retirement direction closed; the route's picker branch stays untouched.

    Generated by Claude Code

  5. os-zhuang commented on Aug 11, 2026

    @os-zhuang
    ContributorAuthor

    OS-DEV-REPORT — dev seat session_01CnMk7vfrt2zW7xvwLS3JDf

    {
      "issue": 7467,
      "outcome": "ci-green",
      "branch": "claude/issue-7467-declare-public-picker",
      "pr_number": 7487,
      "head": "26c8f41",
      "premise_check": "Re-verified on origin/main@afdc6ea before implementing: zero publicPicker hits in packages/spec/src; the card's fixture { field: 'owner', publicPicker: { displayFields: ['name'] } } measured rejected (unrecognized_keys) through the real ViewMetadataSchema; enforcement live in packages/rest/src/rest-server.ts lookup handler. Premise held.",
      "changes": [
        "packages/spec/src/ui/view.zod.ts: FormFieldPublicPickerSchema (strict) + optional publicPicker key on FormFieldBaseSchema; exported FormFieldPublicPicker / FormFieldPublicPickerParsed (ADR-0122); TSDoc states the block gates the public lookup route and that absence answers 403 LOOKUP_NOT_PUBLIC loud-by-design (#3022). Keys mirror the route's reads exactly: displayFields (min 1, max 5 - the route's slice), maxResults (int 1..50 - the route's hard ceiling encoded), filter (ViewFilterRuleSchema[], the dialect the route composes with its own contains-predicate), object (override the route prefers).",
        "packages/spec/src/ui/view-public-picker.test.ts: 12 acceptance cases through the real ViewMetadataSchema.safeParse / FormViewSchema.parse - the card's measured-rejected fixture now parses; empty block parses (opt-in with route defaults); unknown subkey still unrecognized_keys; maxResults 51/0/-5/2.5 rejected; 6th displayField and [] rejected; malformed filter row rejected via #6227 coupling; picker.sort pinned NOT declarable.",
        "packages/rest/src/public-form-routes.test.ts: the 'refuses a publicPicker declared on owner_id' pin rebuilt through the schema - fixture asserted spec-valid via the real ViewMetadataSchema before the route's 403 is asserted (the raw-object bypass that made the gap invisible is gone).",
        "packages/rest/src/public-form-lookup-picker.test.ts (new): stored-row e2e - the real saveMetaItem (stub engine, dispatch-predicate-pinned update/delete) persists a picker-carrying form that used to 422, and the real lookup handler answers 200 with server-side projection pinned (leaked driver column stripped) and the composed query pinned key-for-key; picker-less GUARD still 403.",
        "packages/rest/src/public-form-routes.stored-row.test.ts: #7134's 'BOUNDARY: STILL 403' case flipped after PR #7468 merged into main mid-PR - now 'FLIPPED [#7467]': a groups-authored stored form carrying a spec-valid publicPicker gets real lookup data end-to-end (fold + declaration composing); header and fixture comments updated to stop claiming the key is undeclarable.",
        "packages/spec/liveness/view.json: sections row (the blanket-verdict carrier for the FormField subtree) re-verified 2026-08-11, cross-repo, evidence names the lookup route as the measured reader for the four new keys and records the deliberate sort exception.",
        "docs/audits/2026-06-viewschema-property-liveness.md: post-audit entry for FormField publicPicker{displayFields,maxResults,filter,object}, live, consumer cited.",
        "content/docs/ui/forms.mdx: lookup endpoint documented (publicPicker table, response shape, error table incl. 403 LOOKUP_NOT_PUBLIC and 500 LOOKUP_TARGET_MISSING, per-route auth contexts).",
        "Generated: api-surface/ui.json, export-origins, authorable-surface/ui.json, json-schema.manifest, content/docs/references (gen:docs), strictness-ledger counts - all via generators, none hand-edited."
      ],
      "gates": {
        "ci": "all 26 checks green on 26c8f41 (Console Pin Gate legitimately skipped)",
        "local": "spec suite 374 files / 9801 tests; rest suite green incl. all three public-form files (32 tests); spec+rest typecheck; check:generated 13/13; check:authorable-surface; check:liveness; check:engine-double-contract; check:doc-authoring; rest test-layer tsc debt held at the frozen 155"
      },
      "reverse_verification": {
        "probe_A_delete_schema_block": "predicted 5 spec cases red / negatives green; measured 4 positives red + unknown-subkey case red (missed prediction, recorded - its assertion names the nested key so it is fix-dependent), AND the spec build itself refuses: gen:schema's authorable-surface ratchet reports 'ui/FormField:publicPicker disappeared from the contract', so a dist without the key cannot be produced - the REST e2e red is enforced upstream of the test layer",
        "probe_B_ceiling_51": "predicted exactly the maxResults-51 case red; measured exactly that",
        "probe_C_displayFields_6": "predicted exactly the 6th-displayField case red; measured exactly that",
        "guard_pickerless_403": "green in both directions (public-form-lookup-picker.test.ts GUARD)"
      },
      "changeset": {
        "file": ".changeset/declare-public-picker.md",
        "bump": "minor",
        "justification": "new authorable property on the public acceptance surface - metadata that was a 422 yesterday parses today; precedent #7387 per #3405/#5583; nothing previously accepted changes shape; REST route untouched"
      },
      "deviations": [
        "The card's stored-row pin file did not exist on main at dispatch (it lived in then-open PR #7468). Handled in two steps: the flip's content shipped first as my own e2e file, then #7468 merged mid-PR (01:32Z) and I merged origin/main (no rebase) and rewrote its BOUNDARY case properly. Note the original BOUNDARY fixture carried no picker, so it would NOT have gone mechanically red on this fix - the 'goes red' lived in its comment; the rewrite honors the intent.",
        "displayFields also encodes min(1) and max(5), and maxResults is int-restricted - constraints derived from the route's slice/clamp behavior rather than listed verbatim in the card; per the narrower-schema instruction, authoring a value the route would silently rewrite is refused instead.",
        "One destructive tooling mistake during reverse verification, self-recovered: a git checkout during probe cleanup reverted the not-yet-committed view.zod.ts; re-applied from context, verified by the 12/12 suite and probe reruns, and all subsequent probes ran against committed state."
      ],
      "findings_filed": [
        "#7485 - publicPicker.sort: the route's fifth read stays enforced-never-declarable after this card; needs its own ruling (declare vs retire the read)",
        "#7486 - lookup target fallback reads only legacy field spellings (referenceTo/target/options.objectName), never canonical reference: omitted publicPicker.object answers 500 LOOKUP_TARGET_MISSING against canonical schemas, making the override de-facto required"
      ],
      "docs_adr_touched": "content/docs/ui/forms.mdx (hand-written), docs/audits/2026-06-viewschema-property-liveness.md, packages/spec/liveness/view.json, generated content/docs/references/**. No content/docs/releases/**, no docs/adr/**.",
      "open_questions": [
        "maxResults is declared integer-only; the route's Number() coercion would technically accept fractions - narrower schema chosen per instruction, flagged here rather than widened.",
        "filter is typed as ViewFilterRuleSchema[] (the repo's authoring dialect, matching the shape the route itself composes); the route's own guard is only Array.isArray, so stored pre-schema rows with exotic element shapes remain the route's concern, not the schema's."
      ]
    }

    PR #7487 is a draft with CI green; auto-merge NOT enabled — landing is the PM's call per the claim comment.


    Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions