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
2 changes: 1 addition & 1 deletion packages/rest/src/rest-route-ledger.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -193,7 +193,7 @@ export const REST_ROUTE_LEDGER: readonly RestRouteLedgerEntry[] = [
// `GET /api/v1/meta/:type/:section/:name` — a three-segment literal
// registered after that catch-all is mounted and unreachable.
{ route: 'GET /api/v1/meta/object/:name/state/:field', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getLegalNextStates',
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' },
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)' },
{ route: 'GET /api/v1/meta/:type/:name/published', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getPublished',
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)' },
{ route: 'GET /api/v1/meta/:type/:section/:name/published', family: 'metadata', source: 'route-manager', disposition: 'sdk', client: 'meta.getPublished',
Expand Down
204 changes: 204 additions & 0 deletions packages/runtime/src/domains/meta-state-plural-tolerance.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,204 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* The legacy dispatcher `/meta` if-chain answers the FSM state read under
* BOTH spellings — `objects` and `object` — and that is a DELIBERATE,
* RULED state, not residue nobody has got to yet.
*
* ## Why this file exists at all
*
* `route-ledger.ts` records this asymmetry in a `note`, and its own
* conformance test says so in as many words: *"The ledger's per-route rows
* are documentation; the machine contract here is domain-level"*. Nothing
* checked that note against the code. A ledger note that quietly stops being
* true passes every gate in this repo and is then trusted INSTEAD of the
* source — so the note's load-bearing clause is pinned here as behaviour.
*
* ⇒ Delete the plural arm in `domains/meta.ts` and this file fails, pointing
* at the ruling that has to be reopened first. That is the whole point: the
* arm is protected by a decision, and the decision is now enforceable.
*
* ## The asymmetry, measured on both sides
*
* | front door | `/meta/objects/:name/state/:field` | pinned by |
* |---|---|---|
* | **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` |
* | **`dispatch()`** (the `@objectstack/hono` `createHonoApp` catch-all — the documented embed shape) | **200, answered** | THIS FILE |
*
* `createMetaDomain` registers prefix `/meta` and hands the remainder to
* `handleMetadataRequest`, whose FSM branch matches a hard-coded literal
* PAIR. So the spelling that REST stopped registering in #9180 step ② is
* still served wherever the dispatcher is the front door.
*
* ## Why it stays — the provenance, so nobody has to guess
*
* The maintainer's 2026-08-17 re-weigh of the #9180 ruling, item 3, verbatim
* and untranslated:
*
* > 「② 照原样做;只需要修正 objectstack objectui cloud 中错误的写法。」
*
* which the re-weigh records as: the boundary's tolerance stays for external
* callers, **no new refusals beyond what step ① already shipped**, the
* external break deferred with no scheduled window — reopening it is the
* maintainer's call. Narrowing this arm would be a new refusal on a SECOND
* surface, so it is out of every step's scope by construction.
*
* ⛔ **This is NOT the `META_URL_TO_SINGULAR` fold** whose retirement was
* deferred, and conflating the two is the specific error to avoid. The fold
* is a MAP consulted for `/meta/:type` requests; this is a literal `||` in an
* if-chain that no request ever routes through the fold to reach. They are
* separate mechanisms under separate decisions. The cases below make the
* difference observable rather than asserted: `objectss`, `objectz` and even
* `OBJECTS` all 404, because a literal pair recognises two spellings and
* nothing else — a fold would have normalised at least one of them.
*
* ⛔ And it is NOT "the plural is supported". It is TOLERATED on one front
* door and REFUSED on another. Flattening that in either direction is the
* misreading this pin exists to prevent — hence the ledger-note assertion at
* the bottom, which requires the row to keep naming both spellings.
*/

import { describe, it, expect, vi } from 'vitest';
import { HttpDispatcher } from '../http-dispatcher.js';
import { ROUTE_LEDGER } from '../route-ledger.js';

/**
* One object with one `state_machine` rule. Mirrors the fixture the dogfood
* pin drives over real HTTP, so the two sides of the table above are
* comparing the same question.
*/
const SCHEMA = {
name: 'task',
label: 'Task',
fields: { id: { type: 'text' }, status: { type: 'text' } },
validations: [
{
type: 'state_machine',
name: 'task_status_fsm',
field: 'status',
transitions: { todo: ['backlog', 'in_progress'], done: [] },
},
],
};

function boot() {
const getObject = vi.fn((name: string) => (name === 'task' ? SCHEMA : undefined));
const objectql = { registry: { getObject } };

// `dispatch()` re-resolves identity and overwrites any injected
// `executionContext`, so the session has to come through the real
// resolver — otherwise the domain's anonymous-deny answers 401 before the
// route is ever looked at and the pin would measure the wrong refusal.
const auth = { api: { getSession: async () => ({ user: { id: 'u_session' } }) } };

const services: Record<string, any> = { objectql, auth };
const get = (n: string) => services[n] ?? null;
const kernel = {
context: { getService: get },
getService: get,
getServiceAsync: async (n: string) => get(n),
} as any;

return { dispatcher: new HttpDispatcher(kernel), getObject };
}

const CTX = (): any => ({ request: { headers: {} }, environmentId: 'platform' });

/** The one answer both spellings must produce, spelled once. */
const ANSWER = { object: 'task', field: 'status', from: 'todo', next: ['backlog', 'in_progress'] };

describe('dispatcher /meta FSM state read — the deliberate plural tolerance (#10179)', () => {
it('answers the CANONICAL singular spelling (control)', async () => {
const { dispatcher } = boot();

const res = await dispatcher.dispatch(
'GET', '/meta/object/task/state/status', undefined, { from: 'todo' }, CTX(),
);

expect(res.response?.status ?? 200).toBe(200);
expect(res.response?.body?.data).toEqual(ANSWER);
});

it('ALSO answers the plural spelling — the tolerance the 2026-08-17 re-weigh left in place', async () => {
const { dispatcher } = boot();

const res = await dispatcher.dispatch(
'GET', '/meta/objects/task/state/status', undefined, { from: 'todo' }, CTX(),
);

// ⛔ Do not "fix" this to a 404 because the REST twin is retired. That
// is option (a) of #10179 — a new refusal on a second surface — and it
// needs the maintainer, not a passing thought in a nearby diff.
expect(res.response?.status ?? 200).toBe(200);
expect(res.response?.body?.data).toEqual(ANSWER);
});

it('both spellings reach the SAME handler, not two lookalike answers', async () => {
// The two cases above could in principle be served by different code
// paths that happen to agree. `getObject` being called with the same
// canonical object name from both is what makes them one branch.
const singular = boot();
await singular.dispatcher.dispatch(
'GET', '/meta/object/task/state/status', undefined, { from: 'todo' }, CTX(),
);
const plural = boot();
await plural.dispatcher.dispatch(
'GET', '/meta/objects/task/state/status', undefined, { from: 'todo' }, CTX(),
);

expect(singular.getObject).toHaveBeenCalledWith('task');
expect(plural.getObject).toHaveBeenCalledWith('task');
});

it.each(['objectss', 'objectz', 'OBJECTS'])(
'tolerates EXACTLY the two literals — `%s` is not answered',
async (segment) => {
// The control that stops this file passing vacuously: if the pin
// above were green because the harness answers everything, these
// would be answered too. Measured, not assumed — all three return
// 404 RESOURCE_NOT_FOUND.
//
// It is also the sharpest evidence for the ⛔ in this file's header:
// a hard-coded literal pair recognises `objects` and NOTHING else,
// not even its own uppercase. A fold would have normalised at least
// one of these. This arm never reaches `META_URL_TO_SINGULAR`.
const { dispatcher } = boot();

const res = await dispatcher.dispatch(
'GET', `/meta/${segment}/task/state/status`, undefined, { from: 'todo' }, CTX(),
);

expect(res.response?.status).toBe(404);
expect(res.response?.body?.error?.code).toBe('RESOURCE_NOT_FOUND');
expect(res.response?.body?.data).toBeUndefined();
},
);

it('the plural is a TOLERANCE, not a second contract — an unknown object still 404s under it', async () => {
// Keeps the pin honest in the other direction: the plural arm is the
// same handler with the same refusals, not a laxer door.
const { dispatcher } = boot();

const res = await dispatcher.dispatch(
'GET', '/meta/objects/zzz_not_a_real_object/state/status', undefined, { from: 'todo' }, CTX(),
);

expect(res.response?.status).toBe(404);
});

it('the ledger row keeps NAMING the asymmetry — the note is checked, not trusted', () => {
const row = ROUTE_LEDGER.find((e) => e.route === 'GET /meta/object/:name/state/:field');
expect(row, 'the FSM state row must exist in the dispatcher ledger').toBeDefined();

// The row lists the CANONICAL spelling of a branch that answers two.
// A row that stops saying so is a row that has started lying about the
// surface — the exact failure this file was added to make loud.
const note = row?.note ?? '';
expect(note).toContain('objects');
expect(note).toContain('object');

// …and it must keep the tolerance's provenance reachable, so the next
// author finds the ruling instead of re-deriving it from the if-chain.
expect(note).toContain('2026-08-17');
});
});
2 changes: 1 addition & 1 deletion packages/runtime/src/route-ledger.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -353,7 +353,7 @@ export const ROUTE_LEDGER: readonly RouteLedgerEntry[] = [
{ route: 'POST /meta/_migrate-stored', domain: '/meta', disposition: 'sdk', client: 'meta.migrateStored',
note: 'ADR-0087 stored-row canonicalization (#4327); gated on `manage_metadata`, preview unless { apply: true }' },
{ route: 'GET /meta/object/:name/state/:field', domain: '/meta', disposition: 'sdk', client: 'meta.getLegalNextStates',
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' },
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)' },

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