Skip to content

Commit 53a48c9

Browse files
Elon Muskclaude
andauthored
docs(runtime,rest): record the dispatcher /meta plural tolerance as a ruled state, and pin it (#10613)
The legacy dispatcher `/meta` if-chain answers the FSM state read under both `objects` and `object`, while REST refuses the plural after #9180 step 2. The asymmetry was inferable only from a code comment plus a ledger note that read as "not got to yet" — so an author arriving at either surface could not tell it was deliberate. Both ledger rows now record it with its provenance (the maintainer's 2026-08-17 re-weigh, item 3: no new refusals beyond step 1, external break deferred with no scheduled window), state which front door refuses and which answers, and disclaim the `META_URL_TO_SINGULAR` fold explicitly — a separate mechanism under a separate decision. `meta-state-plural-tolerance.test.ts` makes the note checkable rather than trusted: nothing in this repo checked a ledger note against reality, so a note that quietly stopped being true would pass every gate and then be believed instead of the source. Deleting the plural arm now fails a test that names the ruling to reopen first. Measured, not assumed: `objects` and `object` answer 200; `objectss`, `objectz` and `OBJECTS` all 404 RESOURCE_NOT_FOUND — a literal pair, never the fold. No refusal changes anywhere; `packages/runtime/src/domains/meta.ts` is untouched. Part of #10179 Claude-Session: https://claude.ai/code/session_019bmVFqoQPq63zhKrxdYG1r Co-authored-by: Claude <noreply@anthropic.com>
1 parent 8be5091 commit 53a48c9

3 files changed

Lines changed: 206 additions & 2 deletions

File tree

‎packages/rest/src/rest-route-ledger.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,7 +193,7 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
193193
// `GET /api/v1/meta/:type/:section/:name` — a three-segment literal
194194
// registered after that catch-all is mounted and unreachable.
195195
{ route: 'GET /api/v1/meta/object/:name/state/:field', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getLegalNextStates',
196-
note: 'ADR-0020 D3.3 legal-next-state introspection. `next: null` = no state_machine governs the field, `next: []` = a declared dead end. #9180 step 2 retired the plural `/api/v1/meta/objects/:name/state/:field` twin that used to carry this `sdk` disposition, and the SDK now spells the segment `object` — the `/meta` type segment is singular, always. The retired twin was a DECLARED registration, not a `META_URL_TO_SINGULAR` fold tolerance (this route matches a literal segment and never consulted the fold), so the boundary accept set is unchanged' },
196+
note: 'ADR-0020 D3.3 legal-next-state introspection. `next: null` = no state_machine governs the field, `next: []` = a declared dead end. #9180 step 2 retired the plural `/api/v1/meta/objects/:name/state/:field` twin that used to carry this `sdk` disposition, and the SDK now spells the segment `object` — the `/meta` type segment is singular, always. The retired twin was a DECLARED registration, not a `META_URL_TO_SINGULAR` fold tolerance (this route matches a literal segment and never consulted the fold), so the boundary accept set is unchanged. ⚠ What the retirement did NOT make universal, because an author reading only this row would assume it did: the legacy dispatcher `/meta` if-chain in `packages/runtime/src/domains/meta.ts` still matches BOTH literals, so the plural is refused HERE and still answered wherever `dispatch()` fronts the request instead of this server. That is deliberate, by the maintainer re-weigh of 2026-08-17 (item 3: no new refusals beyond step 1; the external break deferred with no scheduled window), and it is recorded with its provenance on the dispatcher ledger row plus `runtime/src/domains/meta-state-plural-tolerance.test.ts` (#10179)' },
197197
{ route: 'GET /api/v1/meta/:type/:name/published', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getPublished',
198198
note: 'ADR-0033 published snapshot; 404s for a name that does not exist, which the pre-#7526 fall-through into the compound-name route structurally could not do (it answered a protection-envelope stub identical before publish and for a bogus name)' },
199199
{ route: 'GET /api/v1/meta/:type/:section/:name/published', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getPublished',
Lines changed: 204 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* The legacy dispatcher `/meta` if-chain answers the FSM state read under
5+
* BOTH spellings — `objects` and `object` — and that is a DELIBERATE,
6+
* RULED state, not residue nobody has got to yet.
7+
*
8+
* ## Why this file exists at all
9+
*
10+
* `route-ledger.ts` records this asymmetry in a `note`, and its own
11+
* conformance test says so in as many words: *"The ledger's per-route rows
12+
* are documentation; the machine contract here is domain-level"*. Nothing
13+
* checked that note against the code. A ledger note that quietly stops being
14+
* true passes every gate in this repo and is then trusted INSTEAD of the
15+
* source — so the note's load-bearing clause is pinned here as behaviour.
16+
*
17+
* ⇒ Delete the plural arm in `domains/meta.ts` and this file fails, pointing
18+
* at the ruling that has to be reopened first. That is the whole point: the
19+
* arm is protected by a decision, and the decision is now enforceable.
20+
*
21+
* ## The asymmetry, measured on both sides
22+
*
23+
* | front door | `/meta/objects/:name/state/:field` | pinned by |
24+
* |---|---|---|
25+
* | **REST** (`packages/rest` `RestServer`) | **transport 404** — no registration to match | `meta-published-and-state-routes.dogfood.test.ts`, `route-ledger-live-mount-parity.dogfood.test.ts`, `meta-route-registration-order.test.ts` |
26+
* | **`dispatch()`** (the `@objectstack/hono` `createHonoApp` catch-all — the documented embed shape) | **200, answered** | THIS FILE |
27+
*
28+
* `createMetaDomain` registers prefix `/meta` and hands the remainder to
29+
* `handleMetadataRequest`, whose FSM branch matches a hard-coded literal
30+
* PAIR. So the spelling that REST stopped registering in #9180 step ② is
31+
* still served wherever the dispatcher is the front door.
32+
*
33+
* ## Why it stays — the provenance, so nobody has to guess
34+
*
35+
* The maintainer's 2026-08-17 re-weigh of the #9180 ruling, item 3, verbatim
36+
* and untranslated:
37+
*
38+
* > 「② 照原样做;只需要修正 objectstack objectui cloud 中错误的写法。」
39+
*
40+
* which the re-weigh records as: the boundary's tolerance stays for external
41+
* callers, **no new refusals beyond what step ① already shipped**, the
42+
* external break deferred with no scheduled window — reopening it is the
43+
* maintainer's call. Narrowing this arm would be a new refusal on a SECOND
44+
* surface, so it is out of every step's scope by construction.
45+
*
46+
* ⛔ **This is NOT the `META_URL_TO_SINGULAR` fold** whose retirement was
47+
* deferred, and conflating the two is the specific error to avoid. The fold
48+
* is a MAP consulted for `/meta/:type` requests; this is a literal `||` in an
49+
* if-chain that no request ever routes through the fold to reach. They are
50+
* separate mechanisms under separate decisions. The cases below make the
51+
* difference observable rather than asserted: `objectss`, `objectz` and even
52+
* `OBJECTS` all 404, because a literal pair recognises two spellings and
53+
* nothing else — a fold would have normalised at least one of them.
54+
*
55+
* ⛔ And it is NOT "the plural is supported". It is TOLERATED on one front
56+
* door and REFUSED on another. Flattening that in either direction is the
57+
* misreading this pin exists to prevent — hence the ledger-note assertion at
58+
* the bottom, which requires the row to keep naming both spellings.
59+
*/
60+
61+
import { describe, it, expect, vi } from 'vitest';
62+
import { HttpDispatcher } from '../http-dispatcher.js';
63+
import { ROUTE_LEDGER } from '../route-ledger.js';
64+
65+
/**
66+
* One object with one `state_machine` rule. Mirrors the fixture the dogfood
67+
* pin drives over real HTTP, so the two sides of the table above are
68+
* comparing the same question.
69+
*/
70+
const SCHEMA = {
71+
name: 'task',
72+
label: 'Task',
73+
fields: { id: { type: 'text' }, status: { type: 'text' } },
74+
validations: [
75+
{
76+
type: 'state_machine',
77+
name: 'task_status_fsm',
78+
field: 'status',
79+
transitions: { todo: ['backlog', 'in_progress'], done: [] },
80+
},
81+
],
82+
};
83+
84+
function boot() {
85+
const getObject = vi.fn((name: string) => (name === 'task' ? SCHEMA : undefined));
86+
const objectql = { registry: { getObject } };
87+
88+
// `dispatch()` re-resolves identity and overwrites any injected
89+
// `executionContext`, so the session has to come through the real
90+
// resolver — otherwise the domain's anonymous-deny answers 401 before the
91+
// route is ever looked at and the pin would measure the wrong refusal.
92+
const auth = { api: { getSession: async () => ({ user: { id: 'u_session' } }) } };
93+
94+
const services: Record<string, any> = { objectql, auth };
95+
const get = (n: string) => services[n] ?? null;
96+
const kernel = {
97+
context: { getService: get },
98+
getService: get,
99+
getServiceAsync: async (n: string) => get(n),
100+
} as any;
101+
102+
return { dispatcher: new HttpDispatcher(kernel), getObject };
103+
}
104+
105+
const CTX = (): any => ({ request: { headers: {} }, environmentId: 'platform' });
106+
107+
/** The one answer both spellings must produce, spelled once. */
108+
const ANSWER = { object: 'task', field: 'status', from: 'todo', next: ['backlog', 'in_progress'] };
109+
110+
describe('dispatcher /meta FSM state read — the deliberate plural tolerance (#10179)', () => {
111+
it('answers the CANONICAL singular spelling (control)', async () => {
112+
const { dispatcher } = boot();
113+
114+
const res = await dispatcher.dispatch(
115+
'GET', '/meta/object/task/state/status', undefined, { from: 'todo' }, CTX(),
116+
);
117+
118+
expect(res.response?.status ?? 200).toBe(200);
119+
expect(res.response?.body?.data).toEqual(ANSWER);
120+
});
121+
122+
it('ALSO answers the plural spelling — the tolerance the 2026-08-17 re-weigh left in place', async () => {
123+
const { dispatcher } = boot();
124+
125+
const res = await dispatcher.dispatch(
126+
'GET', '/meta/objects/task/state/status', undefined, { from: 'todo' }, CTX(),
127+
);
128+
129+
// ⛔ Do not "fix" this to a 404 because the REST twin is retired. That
130+
// is option (a) of #10179 — a new refusal on a second surface — and it
131+
// needs the maintainer, not a passing thought in a nearby diff.
132+
expect(res.response?.status ?? 200).toBe(200);
133+
expect(res.response?.body?.data).toEqual(ANSWER);
134+
});
135+
136+
it('both spellings reach the SAME handler, not two lookalike answers', async () => {
137+
// The two cases above could in principle be served by different code
138+
// paths that happen to agree. `getObject` being called with the same
139+
// canonical object name from both is what makes them one branch.
140+
const singular = boot();
141+
await singular.dispatcher.dispatch(
142+
'GET', '/meta/object/task/state/status', undefined, { from: 'todo' }, CTX(),
143+
);
144+
const plural = boot();
145+
await plural.dispatcher.dispatch(
146+
'GET', '/meta/objects/task/state/status', undefined, { from: 'todo' }, CTX(),
147+
);
148+
149+
expect(singular.getObject).toHaveBeenCalledWith('task');
150+
expect(plural.getObject).toHaveBeenCalledWith('task');
151+
});
152+
153+
it.each(['objectss', 'objectz', 'OBJECTS'])(
154+
'tolerates EXACTLY the two literals — `%s` is not answered',
155+
async (segment) => {
156+
// The control that stops this file passing vacuously: if the pin
157+
// above were green because the harness answers everything, these
158+
// would be answered too. Measured, not assumed — all three return
159+
// 404 RESOURCE_NOT_FOUND.
160+
//
161+
// It is also the sharpest evidence for the ⛔ in this file's header:
162+
// a hard-coded literal pair recognises `objects` and NOTHING else,
163+
// not even its own uppercase. A fold would have normalised at least
164+
// one of these. This arm never reaches `META_URL_TO_SINGULAR`.
165+
const { dispatcher } = boot();
166+
167+
const res = await dispatcher.dispatch(
168+
'GET', `/meta/${segment}/task/state/status`, undefined, { from: 'todo' }, CTX(),
169+
);
170+
171+
expect(res.response?.status).toBe(404);
172+
expect(res.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND');
173+
expect(res.response?.body?.data).toBeUndefined();
174+
},
175+
);
176+
177+
it('the plural is a TOLERANCE, not a second contract — an unknown object still 404s under it', async () => {
178+
// Keeps the pin honest in the other direction: the plural arm is the
179+
// same handler with the same refusals, not a laxer door.
180+
const { dispatcher } = boot();
181+
182+
const res = await dispatcher.dispatch(
183+
'GET', '/meta/objects/zzz_not_a_real_object/state/status', undefined, { from: 'todo' }, CTX(),
184+
);
185+
186+
expect(res.response?.status).toBe(404);
187+
});
188+
189+
it('the ledger row keeps NAMING the asymmetry — the note is checked, not trusted', () => {
190+
const row = ROUTE_LEDGER.find((e) => e.route === 'GET /meta/object/:name/state/:field');
191+
expect(row, 'the FSM state row must exist in the dispatcher ledger').toBeDefined();
192+
193+
// The row lists the CANONICAL spelling of a branch that answers two.
194+
// A row that stops saying so is a row that has started lying about the
195+
// surface — the exact failure this file was added to make loud.
196+
const note = row?.note ?? '';
197+
expect(note).toContain('objects');
198+
expect(note).toContain('object');
199+
200+
// …and it must keep the tolerance's provenance reachable, so the next
201+
// author finds the ruling instead of re-deriving it from the if-chain.
202+
expect(note).toContain('2026-08-17');
203+
});
204+
});

‎packages/runtime/src/route-ledger.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -353,7 +353,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
353353
{ route: 'POST /meta/_migrate-stored', domain: '/meta', disposition: 'sdk', client: 'meta.migrateStored',
354354
note: 'ADR-0087 stored-row canonicalization (#4327); gated on `manage_metadata`, preview unless { apply: true }' },
355355
{ route: 'GET /meta/object/:name/state/:field', domain: '/meta', disposition: 'sdk', client: 'meta.getLegalNextStates',
356-
note: '#9180 step 2 moved the SDK to the singular spelling and retired the plural REST registration; this row follows the client. The legacy if-chain branch in `domains/meta.ts` still matches BOTH literals (`objects` and `object`) — that tolerance is out of step 2 scope and is not narrowed here, so this row lists the canonical spelling of a branch that answers two' },
356+
note: '#9180 step 2 moved the SDK to the singular spelling and retired the plural REST registration; this row follows the client. DELIBERATE ASYMMETRY, not residue nobody has got to yet: the legacy if-chain branch in `domains/meta.ts` still matches BOTH literals (`objects` and `object`), so `/meta/objects/:name/state/:field` is REFUSED by a REST-fronted deployment (transport 404 — no registration left to match it) and ANSWERED wherever `dispatch()` is the front door (the `createHonoApp` catch-all, the documented embed shape). It stays by the maintainer re-weigh of the #9180 ruling, 2026-08-17 item 3: the tolerance is kept for external callers, no new refusals beyond what step 1 shipped, the external break deferred with no scheduled window — narrowing this arm is a NEW refusal on a SECOND surface and is the maintainer call, not a step of the ruling. ⛔ It is NOT the `META_URL_TO_SINGULAR` fold whose retirement was deferred: that is a map consulted for `/meta/:type`, this is a literal `||` that no request reaches through the fold — separate mechanisms under separate decisions, and conflating them is the specific error to avoid. So this row lists the canonical spelling of a branch that answers two, and `domains/meta-state-plural-tolerance.test.ts` pins BOTH halves so this note cannot quietly stop being true (#10179)' },
357357

358358
// ── data (legacy chain) ───────────────────────────────────────────────────
359359
{ route: 'POST /data/:object/query', domain: '/data', disposition: 'sdk', client: 'data.query' },

0 commit comments

Comments
 (0)