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
39 changes: 39 additions & 0 deletions .changeset/retire-dev-service-marker.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
---
'@objectstack/spec': major
---

**BREAKING**: the legacy `_dev: true` service marker is retired. `readServiceSelfInfo()`
now reads exactly one marker — the standard `__serviceInfo` descriptor — and the
`SERVICE_DEV_MARKER_KEY` export is removed.

FROM → TO, for any service that self-identifies as not-fully-real:

```ts
// FROM — normalized to { status: 'stub', handlerReady: false }
const svc = { _dev: true, chat };

// TO — say which kind of unreal it is
const svc = {
__serviceInfo: {
status: 'stub', // 'stub' = fabricates answers | 'degraded' = really serves, reduced capability
message: 'Development stub — register <PluginName> for a real implementation',
},
chat,
};
```

`handlerReady` defaults to `false` for `stub` and `true` for `degraded`; set it
explicitly when the slot has no HTTP surface at all (`cache` / `queue` / `job`).

**Why it matters if you skip the migration:** a service still carrying `_dev: true`
reads as *unmarked* — i.e. as fully real — so discovery will report it
`status: 'available', handlerReady: true`, and dispatcher domains will call it
instead of refusing it. That is the "fake reported as real" failure ADR-0076 D12
exists to prevent, so migrate rather than leave the marker in place.

Removing rather than aliasing is deliberate: a boolean cannot express the
`stub` / `degraded` split every consumer gates on (a stub's domain refuses it, a
degraded implementation's domain keeps serving it). No producers remained in this
repo when the reader was deleted — plugin-dev's stub table was retired in
ADR-0115, and the kernel's in-memory fallbacks moved onto the descriptor in the
same lineage.
2 changes: 1 addition & 1 deletion docs/adr/0076-objectql-core-tiering.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,4 +214,4 @@ import { SecurityPlugin } from '@objectstack/plugin-security';
- **(1) re-scoped (cross-repo finding)**: the "dead" branches are NOT safely killable — the `@objectstack/hono` `createHonoApp()` catch-all delegates every unmatched `${prefix}/*` to `dispatch()`, and both cloud hosts mount it under their plugin routes. In that composition `/share-links` is the **designed primary surface** (per-env kernels register the `shareLinks` service with `registerShareLinkRoutes:false`; the host dispatcher serves the route after kernel swap — documented in cloud's `artifact-kernel-factory` and `default-environment-plugins`), and `/notifications` has **no other HTTP owner anywhere**. `/data`/`/meta`/`/ui`/`/security` are REST-shadowed but still catch REST misses. Killing therefore waits until the catch-all is retired in favor of per-domain handlers (step 3); until then the branches are the cloud fallback fabric, not dead code.
- **(1)+(3) VERDICT (2026-07-27, #2462 series #3491–#3573): the catch-all is NOT retired — it is rehabilitated, and step (1) is absorbed.** Step (3) decomposed the dispatcher the other way around: every domain body moved to `packages/runtime/src/domains/*` (+ the `action-execution` subsystem), `dispatch()` shrank to the cross-cutting gate stages (env resolution → identity → auth gate → membership → scope strip) followed by a first-match `DomainHandlerRegistry` lookup, and the legacy if-chain reached zero domain branches. The original disease was never the catch-all itself but what it delegated INTO — an unenumerable god if-chain where "declared ≠ mounted ≠ implemented" could hide (the v16 seam bug class). Delegating into a thin gates+registry pipeline is the sane terminal shape: the route table is enumerable (`DomainHandlerRegistry.list()`), every entry has an owner module and seam tests, and the cloud fallback fabric (`/share-links` as the per-env primary surface, `/notifications` as sole owner) rides the SAME pipeline verified end-to-end by `@objectstack/http-conformance` (41 cross-adapter assertions) + the dogfood suite (351) — cloud-side `objectos-runtime` (240 tests incl. the kernel-resolver strategy) passes against the decomposed dispatcher unchanged. "Kill the dead callable branches" (1) is thereby absorbed: there are no branches left to kill, only registry entries with real owners. The optional future polish — having adapters enumerate the registry into explicit per-prefix mounts so the HTTP layer prints its own route table — would require turning the gate stages into per-route middleware; deliberately NOT scheduled (cost exceeds benefit while conformance + route-parity gates hold the line).
10. **Validate multi-adapter** — **Resolved (validated, #2462): the port is free of hard Hono-isms.** A zero-dependency `node:http` reference adapter (`@objectstack/http-conformance`, private QA gate under `packages/qa/`) runs the dispatcher bridge + REST generator unchanged; a cross-adapter conformance suite (40 assertions incl. `/data` CRUD roundtrip, `:param` routing, 404/405 semantics, SSE streaming, discovery) passes identically on both adapters. Findings: the Hono adapter's Host-header backfill is adapter-local (a Fetch-API artifact, not a port leak); all remaining Hono coupling is confined to the `getRawApp()` escape hatch (metadata HMR, cloud-connection/marketplace routes, static/SPA + CORS + Server-Timing), whose consumers feature-detect and degrade. Follow-up for D11: codify the two soft extensions consumers already rely on (`res.write`/`res.end` for SSE, `getPort()`) and 404/405 semantics into the `IHttpServer` contract.
11. **D12 stub marker** — **Resolved (#2462): standard `__serviceInfo` self-descriptor** (`ServiceSelfInfoSchema` in `spec/api/discovery.zod.ts`, read via `readServiceSelfInfo()`), with plugin-dev's legacy `_dev: true` normalized to `{ status: 'stub', handlerReady: false }`. Both discovery builders honor it; the analytics fallback self-identifies as `degraded`.
11. **D12 stub marker** — **Resolved (#2462): standard `__serviceInfo` self-descriptor** (`ServiceSelfInfoSchema` in `spec/api/discovery.zod.ts`, read via `readServiceSelfInfo()`), with plugin-dev's legacy `_dev: true` normalized to `{ status: 'stub', handlerReady: false }`. Both discovery builders honor it; the analytics fallback self-identifies as `degraded`. *(Update #4319: "standardise one" is now literally true — `readServiceSelfInfo` reads the descriptor and nothing else. The `_dev` normalization above is **gone**, and with it the last of the three markers this decision inherited. Neither retirement taught the reader a new dialect: #4082 moved the kernel fallbacks off `_fallback` and ADR-0115 retired the stub table that produced `_dev`, so by the time the branch was deleted it had no producers left in framework, objectui or cloud and fired only from test fixtures. The reason to delete rather than keep a harmless alias is that a boolean cannot say WHICH kind of unreal a service is: `stub` (fabricates — the domain refuses it) and `degraded` (really serves, reduced capability — the domain keeps serving it) is the split every consumer gates on, and `_dev: true` collapsed both into "fake", which is what made "adopt the dispatcher gate everywhere?" unanswerable until #4082 unpicked it. Cost, stated plainly: a service still carrying a retired marker now reads as unmarked, i.e. as fully real — over-reporting, the direction D12 exists to prevent — which is acceptable only because the producer count was zero. The mapping ships in the package CHANGELOG.)*
5 changes: 3 additions & 2 deletions packages/core/src/fallbacks/memory-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@
* [#4058] Self-describes as `degraded`, not `stub` (ADR-0076 D12): this is a
* real cache — it stores, expires, and reports true stats — just process-local
* and unshared. The non-standard `_fallback: true` it used to carry was read by
* nothing (`readServiceSelfInfo` knows `__serviceInfo` and the legacy `_dev`),
* so discovery reported it as fully `available`. `handlerReady: false` because
* nothing (`readServiceSelfInfo` reads only `__serviceInfo` — `_dev`, the other
* marker it knew back then, was itself retired in #4319), so discovery reported
* it as fully `available`. `handlerReady: false` because
* no HTTP surface is mounted for `cache` at all — the same reason realtime
* reports false.
*/
Expand Down
6 changes: 3 additions & 3 deletions packages/objectql/src/protocol-discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,9 +95,9 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>

// ── Honest capabilities (ADR-0076 D12, #2462) ─────────────────────────────

it('should report a _dev-marked service as a stub, never available', async () => {
it('should report a stub-marked service as a stub, never available', async () => {
const mockServices = new Map<string, any>();
mockServices.set('ai', { _dev: true });
mockServices.set('ai', { __serviceInfo: { status: 'stub', message: 'Development stub — not a production implementation' } });

protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const discovery = await protocol.getDiscovery();
Expand Down Expand Up @@ -280,7 +280,7 @@ describe('ObjectStackProtocolImplementation - Dynamic Service Discovery', () =>
// `unavailable` would.
it('should not advertise the analytics route for a self-declared stub', async () => {
const mockServices = new Map<string, any>();
mockServices.set('analytics', { _dev: true, query: async () => ({ rows: [], fields: [] }) });
mockServices.set('analytics', { __serviceInfo: { status: 'stub' }, query: async () => ({ rows: [], fields: [] }) });

protocol = new ObjectStackProtocolImplementation(engine, () => mockServices);
const discovery = await protocol.getDiscovery();
Expand Down
3 changes: 3 additions & 0 deletions packages/runtime/src/app-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1300,6 +1300,9 @@ export class AppPlugin implements Plugin {
// the two ad-hoc markers this branch knew about (`_fallback` / `_dev`).
// `_fallback` was recognized by nothing else — which is exactly how the
// kernel fallbacks carrying it ended up reported as fully `available`.
// Both ad-hoc markers are gone now (#4082 moved their producers onto
// the descriptor; #4319 retired the last `_dev` reader), so this is the
// only spelling left to read.
if (readServiceSelfInfo(i18nService)) {
ctx.logger.info(
`[i18n] Loaded ${loadedLocales} locale(s) into in-memory i18n fallback for "${appId}". ` +
Expand Down
2 changes: 1 addition & 1 deletion packages/runtime/src/dispatcher-plugin.routes.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ describe('createDispatcherPlugin — HTTP route registration', () => {
const { server, routes } = makeFakeServer();
const plugin = createDispatcherPlugin({ prefix: '/api/v1', securityHeaders: false });
await plugin.start?.(ctxWithServices(server, {
analytics: { _dev: true, query: async () => ({ rows: [], fields: [] }) },
analytics: { __serviceInfo: { status: 'stub' }, query: async () => ({ rows: [], fields: [] }) },
}));

for (const r of ANALYTICS_ROUTES) expect(routes).not.toContain(r);
Expand Down
8 changes: 4 additions & 4 deletions packages/runtime/src/http-dispatcher.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -682,7 +682,7 @@ describe('HttpDispatcher', () => {
// (retired with this change). A stub slot is an empty slot.
it('returns unhandled when the analytics slot holds a self-declared stub, without calling it', async () => {
const stub = {
_dev: true,
__serviceInfo: { status: 'stub' },
query: vi.fn().mockResolvedValue({ rows: [], fields: [] }),
getMeta: vi.fn().mockResolvedValue([]),
generateSql: vi.fn().mockResolvedValue({ sql: '', params: [] }),
Expand Down Expand Up @@ -2466,9 +2466,9 @@ describe('HttpDispatcher', () => {
expect(info.services.realtime.status).toBe('unavailable');
});

it('reports a _dev-marked stub service as stub, never available', async () => {
it('reports a stub-marked service as stub, never available', async () => {
(kernel as any).getService = vi.fn().mockImplementation((name: string) => {
if (name === 'ai') return { _dev: true, chat: vi.fn() };
if (name === 'ai') return { __serviceInfo: { status: 'stub', message: 'Development stub — not a production implementation' }, chat: vi.fn() };
return null;
});

Expand Down Expand Up @@ -2504,7 +2504,7 @@ describe('HttpDispatcher', () => {
// `handlerReady: false` says more than `unavailable` would.
it('stops advertising the analytics route for a stub, while still reporting it as a stub', async () => {
(kernel as any).getService = vi.fn().mockImplementation((name: string) =>
name === 'analytics' ? { _dev: true, query: vi.fn() } : null,
name === 'analytics' ? { __serviceInfo: { status: 'stub' }, query: vi.fn() } : null,
);

const info = await dispatcher.getDiscoveryInfo('/api/v1');
Expand Down
8 changes: 4 additions & 4 deletions packages/runtime/src/http-dispatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1006,10 +1006,10 @@ export class HttpDispatcher {
//
// Honest capabilities (ADR-0076 D12, #2462): a registered service that
// self-identifies as a stub / dev fake / degraded fallback (via the
// `__serviceInfo` marker or plugin-dev's legacy `_dev: true`) is
// reported with its declared status — never as `available` — so
// consumers (AI agents, the console) don't mistake a fake capability
// for a real one.
// `__serviceInfo` marker — the one marker left since #4319 retired the
// legacy `_dev` alias) is reported with its declared status — never as
// `available` — so consumers (AI agents, the console) don't mistake a
// fake capability for a real one.
const svcAvailable = (route?: string, provider?: string, svc?: unknown) => {
const self = svc ? readServiceSelfInfo(svc) : undefined;
if (self) {
Expand Down
1 change: 0 additions & 1 deletion packages/spec/api-surface.json
Original file line number Diff line number Diff line change
Expand Up @@ -2984,7 +2984,6 @@
"RouteHealthReportSchema (const)",
"RouterConfig (type)",
"RouterConfigSchema (const)",
"SERVICE_DEV_MARKER_KEY (const)",
"SERVICE_SELF_INFO_KEY (const)",
"SaveMetaItemRequest (type)",
"SaveMetaItemRequestSchema (const)",
Expand Down
17 changes: 11 additions & 6 deletions packages/spec/src/api/discovery.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -918,16 +918,21 @@ describe('Service self-description marker (ADR-0076 D12, #2462)', () => {
expect(readServiceSelfInfo(svc)?.handlerReady).toBe(false);
});

it('readServiceSelfInfo normalizes the legacy _dev marker to a stub', () => {
const info = readServiceSelfInfo({ _dev: true, chat: () => 'fake' });
expect(info?.status).toBe('stub');
expect(info?.handlerReady).toBe(false);
expect(info?.message).toContain('plugin-dev');
// The retired markers read as unmarked — i.e. as fully real. That is the
// deliberate cost of collapsing three dialects into one (#4319): a service
// still carrying `_dev` / `_fallback` is now OVER-reported, not under-. It is
// acceptable only because both had zero producers left when they went (the
// `_fallback` case never worked at all — nothing ever read it, which is how
// the kernel fallbacks came to be reported `available` in the first place),
// and because keeping a boolean alive cannot express the `stub` / `degraded`
// split that every consumer gates on.
it('readServiceSelfInfo does not recognize the retired _dev / _fallback markers', () => {
expect(readServiceSelfInfo({ _dev: true, chat: () => 'fake' })).toBeUndefined();
expect(readServiceSelfInfo({ _fallback: true, get: () => undefined })).toBeUndefined();
});

it('readServiceSelfInfo ignores malformed markers', () => {
expect(readServiceSelfInfo({ [SERVICE_SELF_INFO_KEY]: { status: 'available' } })).toBeUndefined();
expect(readServiceSelfInfo({ [SERVICE_SELF_INFO_KEY]: 'stub' })).toBeUndefined();
expect(readServiceSelfInfo({ _dev: 'yes' })).toBeUndefined();
});
});
32 changes: 14 additions & 18 deletions packages/spec/src/api/discovery.zod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,13 +85,6 @@ export const ServiceInfoSchema = lazySchema(() => z.object({
*/
export const SERVICE_SELF_INFO_KEY = '__serviceInfo' as const;

/**
* Legacy dev-stub marker used by plugin-dev's in-memory fakes.
* Recognized by {@link readServiceSelfInfo} as shorthand for
* `{ status: 'stub', handlerReady: false }`.
*/
export const SERVICE_DEV_MARKER_KEY = '_dev' as const;

/**
* Shape of the {@link SERVICE_SELF_INFO_KEY} marker a service carries to
* describe its own honesty level. Only non-`available` self-reports exist:
Expand Down Expand Up @@ -121,10 +114,20 @@ export type ServiceSelfInfo = z.infer<typeof ServiceSelfInfoSchema>;
* instance (ADR-0076 D12). Returns `undefined` for services that carry no
* marker — i.e. services claiming to be fully real.
*
* Recognizes:
* - `svc[SERVICE_SELF_INFO_KEY]` — the standard `{ status, handlerReady?, message? }` descriptor.
* - `svc[SERVICE_DEV_MARKER_KEY] === true` — plugin-dev's legacy `_dev: true`
* flag, normalized to `{ status: 'stub', handlerReady: false }`.
* Reads exactly one marker: `svc[SERVICE_SELF_INFO_KEY]`, the
* `{ status, handlerReady?, message? }` descriptor.
*
* There were once three. `_fallback: true` (the kernel's in-memory fallbacks)
* was recognized by nothing, so discovery reported those as fully `available`
* — the honesty gap D12 exists to close; `_dev: true` (plugin-dev's stub
* table) was normalized here to `{ status: 'stub', handlerReady: false }`.
* Both were retired by moving their producers onto this descriptor rather than
* by teaching this function more dialects (#4082, ADR-0115): one marker that
* says WHICH kind of unreal a service is beats N markers that only say "unreal"
* — `degraded` (really serves, reduced capability) and `stub` (fabricates) are
* the distinction every consumer actually gates on, and a boolean cannot carry
* it. A service still carrying a retired marker reads as unmarked here, i.e.
* as fully real — migrate it (see the CHANGELOG entry for the mapping).
*/
export function readServiceSelfInfo(svc: unknown): ServiceSelfInfo | undefined {
if (!svc || typeof svc !== 'object') return undefined;
Expand All @@ -138,13 +141,6 @@ export function readServiceSelfInfo(svc: unknown): ServiceSelfInfo | undefined {
...(typeof self.message === 'string' ? { message: self.message } : {}),
};
}
if ((svc as Record<string, unknown>)[SERVICE_DEV_MARKER_KEY] === true) {
return {
status: 'stub',
handlerReady: false,
message: 'Development stub (plugin-dev) — not a production implementation',
};
}
return undefined;
}

Expand Down
Loading