Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .changeset/lookup-picker-reference-only.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
'@objectstack/rest': minor
---

**BREAKING (runtime behaviour on a published route).** The public-form lookup-picker route
`GET /forms/:slug/lookup/:field` resolves its target object from the canonical field key
`reference` alone. The three tolerant fallback arms it used to read after it — the
`referenceTo`, `target` and `options.objectName` spellings — are deleted.

Effect on the wire: a stored object-metadata row whose lookup field carries one of those
three spellings and no `reference` used to answer `200` with rows from the aliased object; it
now answers `500 LOOKUP_TARGET_MISSING`, and the data engine is never called. A field
carrying `reference` is unaffected, including a partially-migrated row carrying a legacy
spelling beside it. `publicPicker.object` on the form is still the explicit override and is
still read first.

No migration is prescribed, and none is owed. `FieldSchema` is a `strictObject` that refuses
`relatedTo`, `referenceTo`, `target`, `targetObject` and `lookupObject` by name, answering
with a rename hint naming the canonical key, so no authoring path can produce such a row; a
census across both trees found no producer and no relation field carrying any of them, with
positive controls; and the maintainer ruled on 2026-09-09 that no deployment holds rows to
preserve. The spec spelling is the contract, and a stored row spelling the target the old way
is a producer defect rather than a dialect this route accommodates.

<!-- 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. -->
2 changes: 1 addition & 1 deletion content/docs/ui/forms.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ sections: [{
| `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']`. |
| `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. |
| `filter` | Static pre-filter rows (same `{ field, operator, value }` dialect as list-view filters), ANDed ahead of the visitor's search. |
| `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. |
| `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. |

Those four keys are the whole block. It admits exactly what the route enforces
— an unknown subkey, a 6th display field, or `maxResults: 51` is a **parse
Expand Down
72 changes: 50 additions & 22 deletions packages/rest/src/public-form-lookup-picker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -355,6 +355,14 @@ describe('#7485 publicPicker.sort is retired — not declarable, and not read',
* `500 LOOKUP_TARGET_MISSING`, making `publicPicker.object` de-facto REQUIRED
* while the schema and docs present it as optional.
*
* ⛔ [#12920] What the fix must NOT be, settled by ruling: the repair was
* never "read the legacy spellings too". The route read `reference` FIRST and
* three legacy spellings after it until 2026-09-09, when the tolerant tail was
* retired — director seat summon #20, decision batch #107 item 5, maintainer
* verbatim 「其他同意」 = option A, executing the 2026-08-30 stance, verbatim
* 「折叠即契约」. This route now reads `reference` and nothing else, and the
* cases below pin that.
*
* Every case below omits `object` deliberately: that is the axis under test.
* The suite above covers the override branch and must stay that way — between
* them the two branches of the resolution are both pinned.
Expand Down Expand Up @@ -385,41 +393,59 @@ describe('#7486 the picker target resolves from the field definition when `objec

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

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

it('the canonical `reference` WINS over a legacy spelling on the same def', async () => {
// Head-of-chain, not merely present-in-chain: a row carrying both (a
// partially-migrated def) must follow the canonical key. Appending
// `reference` to the tail of the chain would pass every case above and
// fail only this one.
it('the canonical `reference` still resolves on a def that ALSO carries a legacy spelling', async () => {
// A partially-migrated def: the canonical key present, an alias beside
// it. `reference` is read and the alias is not consulted at all —
// neither to shadow it nor to break it. Before #12920 this pinned
// head-of-chain ORDER; with one key left it pins that narrowing the
// read did not make a canonical def collateral damage.
const stored = await savedWithoutObject();
const bothObject = {
...leadObject,
Expand Down Expand Up @@ -468,9 +494,11 @@ describe('#7486 the picker target resolves from the field definition when `objec
* schemas in one day, which is what this file's prose used to say too.
*
* ⛔ SCOPE: this pins the SPEC's behaviour and nothing else. It asserts
* nothing about whether any stored row spells a target the legacy way — that
* is #12920's open production census, unanswerable from here, and ⛔ no
* assertion below may be cited as evidence in either direction.
* nothing about whether any stored row spells a target the legacy way. That
* question — #12920's production census — was closed by the maintainer on
* 2026-09-09 ("none to preserve"), ⛔ not from here: it was never answerable
* from a schema pin, and no assertion below may be cited as evidence for it in
* either direction.
*/
describe('#13137 `FieldSchema` REFUSES the legacy target spellings, it does not fold them', () => {
/** `type` is the only required key on `FieldSchema`; everything else is the axis under test. */
Expand Down
91 changes: 39 additions & 52 deletions packages/rest/src/rest-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10658,8 +10658,8 @@ export class RestServer {
// `publicPicker.object` override, fall back to the
// field def on the parent object.
const p = await this.resolveProtocol(environmentId, req);
let referenceTo: string | undefined = picker.object;
if (!referenceTo && typeof (p as any).getMetaItems === 'function') {
let referenceObject: string | undefined = picker.object;
if (!referenceObject && typeof (p as any).getMetaItems === 'function') {
try {
const objectsRequest: TransportScopedMetaRequest<GetMetaItemsRequest> = {
type: 'object',
Expand All @@ -10669,58 +10669,45 @@ export class RestServer {
const items: any[] = Array.isArray(r?.items) ? r.items : Array.isArray(r) ? r : [];
const obj = items.find((o: any) => o?.name === match.object);
const def = obj?.fields?.[fieldName];
// [#7486] `reference` FIRST — it is the canonical
// key on `FieldSchema`, and the ONLY spelling that
// schema accepts. Reading only the legacy spellings
// meant a well-formed object schema carried NONE of
// them, the chain resolved `undefined`, and the
// route answered 500 — making `publicPicker.object`
// de-facto required while the schema and docs
// present it as an optional override.
// [#7486] Resolve the target from the canonical key — and, since
// [#12920], from it ALONE. `reference` is the spelling `FieldSchema`
// accepts, so it is the only spelling a field def can legitimately
// carry.
//
// ⛔ [#13137] `data/field.zod.ts` does NOT fold the
// legacy spellings onto `reference`. An earlier
// version of this comment said it did, and that
// sentence is precisely what invited consumers to
// be lenient. Its `aliases` table is a RENAME HINT
// ON A REJECTED KEY, not a normaliser:
// `strictObject` consults `aliases` only from the
// `unrecognized_keys` path (the semantics are
// stated in `spec/src/shared/strict-object.ts`), so
// `relatedTo` / `referenceTo` / `target` /
// `targetObject` / `lookupObject` are REFUSED by
// `FieldSchema` — answered with *"Did you mean
// `referenceTo` → `reference`?"* and never
// rewritten. Pinned three ways (accept /
// alias-refusal-with-hint / unknown-key-refusal
// -without-hint) in
// `public-form-lookup-picker.test.ts`.
// ⇒ ⛔ this chain is NOT licence to be lenient
// anywhere else: nothing upstream folds for you,
// and a producer emitting a legacy spelling emits a
// document the spec refuses by name.
// ⛔ [#12920] This read used to be a four-spelling tolerant chain
// (`reference ?? referenceTo ?? target ?? options.objectName`). It was
// RETIRED by ruling — director seat summon #20, decision batch #107
// item 5, 2026-09-09, maintainer verbatim 「其他同意」 = option A —
// executing the stance recorded 2026-08-30, verbatim 「折叠即契约」:
// the spec spelling IS the contract, and a stored row spelling the
// target the old way is a PRODUCER defect, not a shape this route
// accommodates. The prerequisite that had held execution — whether any
// live deployment holds alias-spelled rows — was answered by the
// maintainer: none to preserve.
//
// The tail below reads exactly three spellings —
// `referenceTo`, `target`, `options.objectName` —
// which is NOT the spec's five-entry hint list:
// only the first two appear on it, and
// `options.objectName` appears on no list at all.
// They can reach here only on a STORED row that
// never went through `FieldSchema`, which is
// possible because the serving read path replays
// ADR-0087 conversions
// (`applyConversionsToStoredItem`) and performs no
// schema validation. ⚠️ Whether such a row is still
// reachable in production is #12920's OPEN census —
// ⛔ do not widen this chain here, and do not narrow
// it here either; #12920 decides its fate.
referenceTo = def?.reference
?? def?.referenceTo
?? def?.target
?? def?.options?.objectName;
// Wire-visible consequence, deliberate: a stored def spelling the
// target `referenceTo` / `target` / `options.objectName` now resolves
// NOTHING here, and the route answers `500 LOOKUP_TARGET_MISSING`
// instead of searching the aliased object. Pinned, in both directions,
// in `public-form-lookup-picker.test.ts`.
//
// ⛔ Do not re-widen this read, here or in any sibling consumer —
// widening it back is how the platform came to answer the same
// question differently per consumer. Nothing upstream folds for you:
// [#13137] `data/field.zod.ts`'s `aliases` table is a RENAME HINT ON A
// REJECTED KEY, not a normaliser (`strictObject` consults it solely
// from the `unrecognized_keys` path — the semantics are stated in
// `spec/src/shared/strict-object.ts`), so `relatedTo` / `referenceTo` /
// `target` / `targetObject` / `lookupObject` are REFUSED by
// `FieldSchema`, answered with *"Did you mean `referenceTo` →
// `reference`?"*, and never rewritten. The one place an alias IS
// tolerated is the ADR-0087 conversion layer (`fieldReferenceToAlias`),
// replayed on stored-row rehydration — declared, tested and removable
// on a schedule, which a `??` arm here never was.
referenceObject = def?.reference;
} catch {/* ignore */}
}
if (!referenceTo) {
if (!referenceObject) {
res.status(500).json({
code: 'LOOKUP_TARGET_MISSING',
error: `Could not resolve referenced object for "${fieldName}"`,
Expand Down Expand Up @@ -10768,7 +10755,7 @@ export class RestServer {
};

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