Skip to content

Commit 3644fad

Browse files
os-justinclaude
andauthored
fix(rest): resolve the public-form lookup picker target from reference only (#17449)
* fix(rest): resolve the public-form lookup picker target from `reference` only The lookup-picker route read a four-spelling tolerant chain (`reference ?? referenceTo ?? target ?? options.objectName`). Retire the tolerant tail: the spec spelling is the contract, and a stored row spelling the target the old way is a producer defect, not a dialect this route accommodates. The three cases that pinned "a legacy spelling still resolves" now pin the refusal in both halves -- `500 LOOKUP_TARGET_MISSING` and the data engine never called -- and the file's two "open production census" notes are replaced with the ruling that closed it. Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com> * docs(forms): the public picker resolves its target from `reference` only The `publicPicker.object` row described the tolerant chain this branch just retired, so it now states the opposite of what the route does. Say what the route does, and name the status an alias-spelled stored row now gets. Claude-Session: https://claude.ai/code/session_01DapQyvYrFb1MxSYe7BL2nt Co-authored-by: Claude <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 06df327 commit 3644fad

4 files changed

Lines changed: 115 additions & 75 deletions

File tree

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
---
2+
'@objectstack/rest': minor
3+
---
4+
5+
**BREAKING (runtime behaviour on a published route).** The public-form lookup-picker route
6+
`GET /forms/:slug/lookup/:field` resolves its target object from the canonical field key
7+
`reference` alone. The three tolerant fallback arms it used to read after it — the
8+
`referenceTo`, `target` and `options.objectName` spellings — are deleted.
9+
10+
Effect on the wire: a stored object-metadata row whose lookup field carries one of those
11+
three spellings and no `reference` used to answer `200` with rows from the aliased object; it
12+
now answers `500 LOOKUP_TARGET_MISSING`, and the data engine is never called. A field
13+
carrying `reference` is unaffected, including a partially-migrated row carrying a legacy
14+
spelling beside it. `publicPicker.object` on the form is still the explicit override and is
15+
still read first.
16+
17+
No migration is prescribed, and none is owed. `FieldSchema` is a `strictObject` that refuses
18+
`relatedTo`, `referenceTo`, `target`, `targetObject` and `lookupObject` by name, answering
19+
with a rename hint naming the canonical key, so no authoring path can produce such a row; a
20+
census across both trees found no producer and no relation field carrying any of them, with
21+
positive controls; and the maintainer ruled on 2026-09-09 that no deployment holds rows to
22+
preserve. The spec spelling is the contract, and a stored row spelling the target the old way
23+
is a producer defect rather than a dialect this route accommodates.
24+
25+
<!-- adr-0087: not-required (no-migration-prescription) This narrows a REST route's runtime read, not a metadata surface: no Zod schema, spec declaration or stored representation changes here, and `objectstack migrate meta` has nothing to rewrite for it. The one at-rest spelling with a measured population is `reference_to`, already carried by the pre-existing ADR-0087 entry `field-reference-to-alias`, which is untouched by this change and disjoint from the three spellings it removes; those three have no at-rest population and are refused by name at the write door, so a new ledger entry would be scope invented at conversion time. -->

‎content/docs/ui/forms.mdx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -257,7 +257,7 @@ sections: [{
257257
| `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']`. |
258258
| `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. |
259259
| `filter` | Static pre-filter rows (same `{ field, operator, value }` dialect as list-view filters), ANDed ahead of the visitor's search. |
260-
| `object` | The object to search. Optional — omit it and the server resolves the target from the field's own definition on the parent object (its `reference`, or a legacy `referenceTo` / `target` / `options.objectName` on a pre-fold stored row). Declare it only to search something other than what the field points at. |
260+
| `object` | The object to search. Optional — omit it and the server resolves the target from the field's own definition on the parent object: its `reference` key, and only that key. A stored row spelling the target `referenceTo` / `target` / `options.objectName` is **not** resolved — the route answers `500 LOOKUP_TARGET_MISSING` — because `FieldSchema` accepts no spelling but `reference`. Declare it only to search something other than what the field points at. |
261261

262262
Those four keys are the whole block. It admits exactly what the route enforces
263263
— an unknown subkey, a 6th display field, or `maxResults: 51` is a **parse

‎packages/rest/src/public-form-lookup-picker.test.ts‎

Lines changed: 50 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -355,6 +355,14 @@ describe('#7485 publicPicker.sort is retired — not declarable, and not read',
355355
* `500 LOOKUP_TARGET_MISSING`, making `publicPicker.object` de-facto REQUIRED
356356
* while the schema and docs present it as optional.
357357
*
358+
* ⛔ [#12920] What the fix must NOT be, settled by ruling: the repair was
359+
* never "read the legacy spellings too". The route read `reference` FIRST and
360+
* three legacy spellings after it until 2026-09-09, when the tolerant tail was
361+
* retired — director seat summon #20, decision batch #107 item 5, maintainer
362+
* verbatim 「其他同意」 = option A, executing the 2026-08-30 stance, verbatim
363+
* 「折叠即契约」. This route now reads `reference` and nothing else, and the
364+
* cases below pin that.
365+
*
358366
* Every case below omits `object` deliberately: that is the axis under test.
359367
* The suite above covers the override branch and must stay that way — between
360368
* them the two branches of the resolution are both pinned.
@@ -385,41 +393,59 @@ describe('#7486 the picker target resolves from the field definition when `objec
385393

386394
// ⛔ [#13137] These are spellings `FieldSchema` REFUSES. There is no fold,
387395
// so there is no "pre-fold row" for one of these to be — an earlier version
388-
// of this comment said there was, and #12920 cites that sentence as its
389-
// strongest surviving evidence that stored legacy rows exist. It carries
396+
// of this comment said there was, and #12920 cited that sentence as its
397+
// strongest surviving evidence that stored legacy rows exist. It carried
390398
// ZERO observational content about stored data, and neither do these cases.
391399
//
392-
// What is true and what these pin: a def spelling the target this way never
393-
// came through `FieldSchema`, and the serving read path replays ADR-0087
394-
// conversions (`applyConversionsToStoredItem`) without any schema
395-
// validation, so such a def would reach the route verbatim. These cases pin
396-
// what the route DOES with one. ⚠️ Whether any exists is #12920's open
397-
// production census — ⛔ assert nothing here in either direction.
398-
// The fix EXTENDS the chain; one that replaced it would turn these three
399-
// green cases into 500s.
400+
// ⭐ [#12920] RULED, and these cases now pin the ruling. The open
401+
// production census this block used to suspend judgement on — "is a stored
402+
// alias-spelled row reachable in a live deployment?" — was answered by the
403+
// maintainer, not by a scan: NONE to preserve, consistent with the
404+
// 2026-08-27 startup-phase principle (no staged transitions) and with the
405+
// in-tree census (zero producers, zero relation fields spelling the target
406+
// with an alias, positive controls fired). ⇒ the route's four-spelling
407+
// tolerant chain retired; a stored row spelling the target the old way is
408+
// a PRODUCER defect, and this route refuses it like every other consumer.
409+
//
410+
// What is true and unchanged: such a def never came through `FieldSchema`,
411+
// and the serving read path replays ADR-0087 conversions
412+
// (`applyConversionsToStoredItem`) without any schema validation, so it
413+
// WOULD reach the route verbatim. These cases pin what the route does with
414+
// one — refuse it, loudly, with the service never called.
415+
//
416+
// ⚠️ Direction matters: all three are RED against the four-arm chain (it
417+
// answers 200 and searches `sys_user`) and green against the one-key read.
418+
// ⛔ A pin that passed in both states would be no evidence at all.
400419
const LEGACY_DEFS: Array<[string, Record<string, unknown>]> = [
401420
['referenceTo', { type: 'lookup', referenceTo: 'sys_user' }],
402421
['target', { type: 'lookup', target: 'sys_user' }],
403422
['options.objectName', { type: 'lookup', options: { objectName: 'sys_user' } }],
404423
];
405424
for (const [spelling, ownerDef] of LEGACY_DEFS) {
406-
it(`a stored row spelling the target the LEGACY way (\`${spelling}\`) still resolves`, async () => {
425+
it(`a stored row spelling the target the LEGACY way (\`${spelling}\`) is NOT resolved`, async () => {
407426
const stored = await savedWithoutObject();
408427
const legacyObject = { ...leadObject, fields: { ...leadObject.fields, owner: ownerDef } };
409-
const { findData, lookup } = routesOver(stored, [], legacyObject);
428+
// The engine is loaded with a row a resolving route WOULD return,
429+
// so the red state is a 200 carrying data, not an empty 200.
430+
const { findData, lookup } = routesOver(stored, [{ id: 'usr_1', name: 'Ada', email: 'ada@example.com' }], legacyObject);
410431
const res = mockRes();
411432
await lookup.handler({ params: { slug: 'contact', field: 'owner' }, query: {} } as any, res);
412433

413-
expect(res.statusCode).toBe(200);
414-
expect(findData.mock.calls[0][0].object).toBe('sys_user');
434+
expect(res.statusCode).toBe(500);
435+
expect(res.body.code).toBe('LOOKUP_TARGET_MISSING');
436+
// ⛔ Both halves. The status alone cannot separate "refused the
437+
// alias" from "resolved it and the search came back empty"; only
438+
// the never-called half says the target was never resolved.
439+
expect(findData).not.toHaveBeenCalled();
415440
});
416441
}
417442

418-
it('the canonical `reference` WINS over a legacy spelling on the same def', async () => {
419-
// Head-of-chain, not merely present-in-chain: a row carrying both (a
420-
// partially-migrated def) must follow the canonical key. Appending
421-
// `reference` to the tail of the chain would pass every case above and
422-
// fail only this one.
443+
it('the canonical `reference` still resolves on a def that ALSO carries a legacy spelling', async () => {
444+
// A partially-migrated def: the canonical key present, an alias beside
445+
// it. `reference` is read and the alias is not consulted at all —
446+
// neither to shadow it nor to break it. Before #12920 this pinned
447+
// head-of-chain ORDER; with one key left it pins that narrowing the
448+
// read did not make a canonical def collateral damage.
423449
const stored = await savedWithoutObject();
424450
const bothObject = {
425451
...leadObject,
@@ -468,9 +494,11 @@ describe('#7486 the picker target resolves from the field definition when `objec
468494
* schemas in one day, which is what this file's prose used to say too.
469495
*
470496
* ⛔ SCOPE: this pins the SPEC's behaviour and nothing else. It asserts
471-
* nothing about whether any stored row spells a target the legacy way — that
472-
* is #12920's open production census, unanswerable from here, and ⛔ no
473-
* assertion below may be cited as evidence in either direction.
497+
* nothing about whether any stored row spells a target the legacy way. That
498+
* question — #12920's production census — was closed by the maintainer on
499+
* 2026-09-09 ("none to preserve"), ⛔ not from here: it was never answerable
500+
* from a schema pin, and no assertion below may be cited as evidence for it in
501+
* either direction.
474502
*/
475503
describe('#13137 `FieldSchema` REFUSES the legacy target spellings, it does not fold them', () => {
476504
/** `type` is the only required key on `FieldSchema`; everything else is the axis under test. */

‎packages/rest/src/rest-server.ts‎

Lines changed: 39 additions & 52 deletions
Original file line numberDiff line numberDiff line change
@@ -10658,8 +10658,8 @@ export class RestServer {
1065810658
// `publicPicker.object` override, fall back to the
1065910659
// field def on the parent object.
1066010660
const p = await this.resolveProtocol(environmentId, req);
10661-
let referenceTo: string | undefined = picker.object;
10662-
if (!referenceTo && typeof (p as any).getMetaItems === 'function') {
10661+
let referenceObject: string | undefined = picker.object;
10662+
if (!referenceObject && typeof (p as any).getMetaItems === 'function') {
1066310663
try {
1066410664
const objectsRequest: TransportScopedMetaRequest<GetMetaItemsRequest> = {
1066510665
type: 'object',
@@ -10669,58 +10669,45 @@ export class RestServer {
1066910669
const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
1067010670
const obj = items.find((o: any) => o?.name === match.object);
1067110671
const def = obj?.fields?.[fieldName];
10672-
// [#7486] `reference` FIRST — it is the canonical
10673-
// key on `FieldSchema`, and the ONLY spelling that
10674-
// schema accepts. Reading only the legacy spellings
10675-
// meant a well-formed object schema carried NONE of
10676-
// them, the chain resolved `undefined`, and the
10677-
// route answered 500 — making `publicPicker.object`
10678-
// de-facto required while the schema and docs
10679-
// present it as an optional override.
10672+
// [#7486] Resolve the target from the canonical key — and, since
10673+
// [#12920], from it ALONE. `reference` is the spelling `FieldSchema`
10674+
// accepts, so it is the only spelling a field def can legitimately
10675+
// carry.
1068010676
//
10681-
// ⛔ [#13137] `data/field.zod.ts` does NOT fold the
10682-
// legacy spellings onto `reference`. An earlier
10683-
// version of this comment said it did, and that
10684-
// sentence is precisely what invited consumers to
10685-
// be lenient. Its `aliases` table is a RENAME HINT
10686-
// ON A REJECTED KEY, not a normaliser:
10687-
// `strictObject` consults `aliases` only from the
10688-
// `unrecognized_keys` path (the semantics are
10689-
// stated in `spec/src/shared/strict-object.ts`), so
10690-
// `relatedTo` / `referenceTo` / `target` /
10691-
// `targetObject` / `lookupObject` are REFUSED by
10692-
// `FieldSchema` — answered with *"Did you mean
10693-
// `referenceTo` → `reference`?"* and never
10694-
// rewritten. Pinned three ways (accept /
10695-
// alias-refusal-with-hint / unknown-key-refusal
10696-
// -without-hint) in
10697-
// `public-form-lookup-picker.test.ts`.
10698-
// ⇒ ⛔ this chain is NOT licence to be lenient
10699-
// anywhere else: nothing upstream folds for you,
10700-
// and a producer emitting a legacy spelling emits a
10701-
// document the spec refuses by name.
10677+
// ⛔ [#12920] This read used to be a four-spelling tolerant chain
10678+
// (`reference ?? referenceTo ?? target ?? options.objectName`). It was
10679+
// RETIRED by ruling — director seat summon #20, decision batch #107
10680+
// item 5, 2026-09-09, maintainer verbatim 「其他同意」 = option A —
10681+
// executing the stance recorded 2026-08-30, verbatim 「折叠即契约」:
10682+
// the spec spelling IS the contract, and a stored row spelling the
10683+
// target the old way is a PRODUCER defect, not a shape this route
10684+
// accommodates. The prerequisite that had held execution — whether any
10685+
// live deployment holds alias-spelled rows — was answered by the
10686+
// maintainer: none to preserve.
1070210687
//
10703-
// The tail below reads exactly three spellings —
10704-
// `referenceTo`, `target`, `options.objectName` —
10705-
// which is NOT the spec's five-entry hint list:
10706-
// only the first two appear on it, and
10707-
// `options.objectName` appears on no list at all.
10708-
// They can reach here only on a STORED row that
10709-
// never went through `FieldSchema`, which is
10710-
// possible because the serving read path replays
10711-
// ADR-0087 conversions
10712-
// (`applyConversionsToStoredItem`) and performs no
10713-
// schema validation. ⚠️ Whether such a row is still
10714-
// reachable in production is #12920's OPEN census —
10715-
// ⛔ do not widen this chain here, and do not narrow
10716-
// it here either; #12920 decides its fate.
10717-
referenceTo = def?.reference
10718-
?? def?.referenceTo
10719-
?? def?.target
10720-
?? def?.options?.objectName;
10688+
// Wire-visible consequence, deliberate: a stored def spelling the
10689+
// target `referenceTo` / `target` / `options.objectName` now resolves
10690+
// NOTHING here, and the route answers `500 LOOKUP_TARGET_MISSING`
10691+
// instead of searching the aliased object. Pinned, in both directions,
10692+
// in `public-form-lookup-picker.test.ts`.
10693+
//
10694+
// ⛔ Do not re-widen this read, here or in any sibling consumer —
10695+
// widening it back is how the platform came to answer the same
10696+
// question differently per consumer. Nothing upstream folds for you:
10697+
// [#13137] `data/field.zod.ts`'s `aliases` table is a RENAME HINT ON A
10698+
// REJECTED KEY, not a normaliser (`strictObject` consults it solely
10699+
// from the `unrecognized_keys` path — the semantics are stated in
10700+
// `spec/src/shared/strict-object.ts`), so `relatedTo` / `referenceTo` /
10701+
// `target` / `targetObject` / `lookupObject` are REFUSED by
10702+
// `FieldSchema`, answered with *"Did you mean `referenceTo` →
10703+
// `reference`?"*, and never rewritten. The one place an alias IS
10704+
// tolerated is the ADR-0087 conversion layer (`fieldReferenceToAlias`),
10705+
// replayed on stored-row rehydration — declared, tested and removable
10706+
// on a schedule, which a `??` arm here never was.
10707+
referenceObject = def?.reference;
1072110708
} catch {/* ignore */}
1072210709
}
10723-
if (!referenceTo) {
10710+
if (!referenceObject) {
1072410711
res.status(500).json({
1072510712
code: 'LOOKUP_TARGET_MISSING',
1072610713
error: `Could not resolve referenced object for "${fieldName}"`,
@@ -10768,7 +10755,7 @@ export class RestServer {
1076810755
};
1076910756

1077010757
const pickerRequest: ServerScopedDataRequest<FindDataRequest> = {
10771-
object: referenceTo,
10758+
object: referenceObject,
1077210759
// [#16337] Canonical QueryAST: `filters` → `where`,
1077310760
// `select` → `fields`, `sort` → `orderBy`. The normalizer
1077410761
// folds each of those aliases onto exactly these keys and
@@ -10786,7 +10773,7 @@ export class RestServer {
1078610773
// all; that the value is now a filter the ingress ACCEPTS
1078710774
// is measured end-to-end, not asserted by the type.
1078810775
query: {
10789-
object: referenceTo,
10776+
object: referenceObject,
1079010777
limit: maxResults,
1079110778
offset: 0,
1079210779
where: filters,

0 commit comments

Comments
 (0)