diff --git a/packages/rest/src/rest-server.ts b/packages/rest/src/rest-server.ts index 96503ffc4b..95c26ffd28 100644 --- a/packages/rest/src/rest-server.ts +++ b/packages/rest/src/rest-server.ts @@ -6296,10 +6296,16 @@ export class RestServer { }); return; } - // `next: null` = no FSM governs the field; `next: []` = - // a declared dead end. Same three-valued answer the - // dispatcher gives, because a UI asking "where can this - // record go" must be able to tell those apart. + // Three answer values — `next: null`, `next: []` (a + // declared dead end), and the legal-next list — the same + // answer the dispatcher gives, because a UI asking "where + // can this record go" must tell those apart. But `null` + // is overloaded across TWO input conditions: no FSM + // governs the field, or the caller omitted `?from=` (no + // `from` => no transition table to answer with), which + // the line below folds onto the same `null` without + // consulting the rule. A UI therefore cannot read `null` + // as "no state machine" unless it passed a `from`. const next = from === undefined ? null : legalNextStates(schema, field, from); res.json({ object: name, field, from: from ?? null, next }); } catch (error: any) { diff --git a/packages/runtime/src/domains/meta.ts b/packages/runtime/src/domains/meta.ts index 6de7ad462f..a8bc150b8a 100644 --- a/packages/runtime/src/domains/meta.ts +++ b/packages/runtime/src/domains/meta.ts @@ -232,8 +232,13 @@ export async function handleMetadataRequest(deps: DomainHandlerDeps, path: strin // ADR-0020 D3.3 introspection: the legal next states declared by the // object's `state_machine` validation rule for `:field`. Lets UIs / // AI authors ask "from here, where can this record go?" instead of - // hard-coding the transition table. Returns `next: null` when no FSM - // governs the field, `next: []` for a declared dead-end state. + // hard-coding the transition table. `next: []` is a declared + // dead-end state. `next: null` has TWO causes: no FSM governs the + // field, or the caller omitted `?from=` (no `from` => no transition + // table to answer with) — the handler short-circuits on that before + // it ever consults the rule. So a `null` answered to a call + // that passed no `from` is not evidence the field has no state + // machine; re-ask with `?from=`. if (parts.length === 4 && (parts[0] === 'objects' || parts[0] === 'object') && parts[2] === 'state' && (!method || method === 'GET')) { const name = parts[1]; const field = parts[3];