Skip to content

Commit 5b791de

Browse files
committed
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>
1 parent 9224204 commit 5b791de

3 files changed

Lines changed: 114 additions & 74 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. -->

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)