From a656ea1ff2243bba033c4d31b050b4e7a5fabe47 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 13:17:24 +0000 Subject: [PATCH 1/3] fix(runtime): canonicalise the API root to the discovery route (#17625) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `dispatch()` strips one trailing slash, so both root spellings the dispatcher accepts — `${prefix}/` (arriving as `/`) and `${prefix}` (arriving as ``, the MSW/base-URL-stripped form) — collapsed onto the empty string. Only the discovery branch at the foot of the method knew that meant the API root; the ADR-0069 gate, which runs far above it, did not. That disagreement was invisible while `isAuthGateAllowlisted` exempted a falsy path, and became a 403 on the bare-root discovery request once the predicate went fail-closed. Normalising the root to `/` would relocate the 403 rather than remove it: a segment-less path matches no `ALLOW_ROUTES` entry, and the discovery branch tests `/discovery` or the empty string, neither of which `/` satisfies. The root is canonicalised to `/discovery` instead — the route it has always served — read from one constant by both sites so the two cannot drift again. `packages/core` is untouched and `ALLOW_ROUTES` is unchanged: the only input whose gate answer moves is the API root, which gains exactly the exemption `/discovery` already carried, and gains it by being that route. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- .../http-dispatcher.root-auth-gate.test.ts | 200 ++++++++++++++++++ packages/runtime/src/http-dispatcher.ts | 76 ++++++- 2 files changed, 273 insertions(+), 3 deletions(-) create mode 100644 packages/runtime/src/http-dispatcher.root-auth-gate.test.ts diff --git a/packages/runtime/src/http-dispatcher.root-auth-gate.test.ts b/packages/runtime/src/http-dispatcher.root-auth-gate.test.ts new file mode 100644 index 0000000000..4aaac9dfc9 --- /dev/null +++ b/packages/runtime/src/http-dispatcher.root-auth-gate.test.ts @@ -0,0 +1,200 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#17625, the runtime half of #7898's ruling A] The API root reaches the + * discovery payload for a GATED session. + * + * ## What moved, and why this file exists at all + * + * `dispatch()` strips one trailing slash, so BOTH root spellings the + * dispatcher accepts — `${prefix}/` (arriving as `'/'`) and `${prefix}` + * (arriving as `''`, the MSW/base-URL-stripped form) — used to travel on as + * `''`. Only the discovery branch at the foot of the method knew that meant + * the API root; the ADR-0069 gate, which runs far above it, did not. While + * `isAuthGateAllowlisted` answered `true` for a falsy path that disagreement + * was invisible. #7898 made the predicate fail-closed, and the bare-root + * discovery request started answering 403. + * + * ⚠️ The obvious repair is measured WRONG upstream and must not be re-tried + * here: normalising `'' → '/'` relocates the 403 instead of removing it. + * `isAuthGateAllowlisted('/')` is `false` (a segment-less path matches no + * `ALLOW_ROUTES` entry) and the discovery branch tests `'/discovery'` or `''`, + * which `'/'` satisfies neither. Both legs are pinned in + * `packages/core/src/security/auth-gate.test.ts` → "does not exempt the + * dispatcher bare-root `cleanPath` — step 2 is #17625". + * + * The delivered repair canonicalises the root to `'/discovery'` — the route it + * has always served — so the gate and the branch read one spelling. ⛔ Core is + * untouched and `ALLOW_ROUTES` is unchanged: the root gains exactly the + * exemption `/discovery` already carried, and gains it by BEING that route. + * + * ## The genuinely-absent-path leg is NOT restated here + * + * "A caller that reaches the gate with no path at all is still refused" is + * `packages/core`'s pin, delivered by #7898's own round + * (`auth-gate.test.ts` → "[#7898] a falsy path is not exempt (fail-closed)", + * which drives `isAuthGateAllowlisted` over `undefined`, `null` and `''`). + * ⛔ Restating it against `HttpDispatcher` would measure nothing new: this + * transport has no pathless call shape — `dispatch()` takes `path: string` and + * the root canonicalisation below is reached only from the two ROOT spellings. + * Referenced, not duplicated. + * + * ## Why every case runs on a fixture whose gate is provably ON + * + * `enforceAuthGate` fails open in a great many ways — no `auth` service, no + * `isAuthGateActive`, no `getSession`, an unreadable header bag, any thrown + * error — and under every one of them a 200 on the root is indistinguishable + * from the repair working. So the gated fixture below is paired with a + * POSITIVE CONTROL on a protected path in the same `describe`: if the control + * stops answering 403 with the gate's own code, every other case in this file + * is measuring a gate that is simply off, and the file says so by going red. + */ + +import { describe, it, expect, vi } from 'vitest'; +import { HttpDispatcher } from './http-dispatcher.js'; + +/** A session user carrying an ADR-0069 gate posture (`normalizeAuthGate`'s shape). */ +const GATED_USER = { + id: 'u_gated', + authGate: { code: 'PASSWORD_EXPIRED', message: 'Your password has expired.' }, +}; + +/** The same user with no gate — the negative-direction control. */ +const UNGATED_USER = { id: 'u_clear' }; + +/** A path nothing allow-lists, used as the gate's positive control. */ +const PROTECTED_PATH = '/data/task'; + +function makeDispatcher(sessionUser: unknown, gateActive = true) { + const services: Record = { + objectql: { + find: vi.fn().mockResolvedValue([]), + getObjects: vi.fn().mockReturnValue({}), + registry: { + getObject: vi.fn().mockReturnValue(null), + getRegisteredTypes: vi.fn().mockReturnValue([]), + }, + }, + auth: { + isAuthGateActive: () => gateActive, + getApi: async () => ({ getSession: async () => ({ user: sessionUser }) }), + }, + }; + const kernel: any = { + getState: () => 'running', + getService: (n: string) => services[n] ?? null, + getServiceAsync: async (n: string) => services[n] ?? null, + context: { getService: (n: string) => services[n] ?? null }, + }; + return new HttpDispatcher(kernel, undefined, { enforceProjectMembership: false }); +} + +/** + * Drive one request and hand back the result plus the context the dispatcher + * wrote through. `routePath` is the value `prepareResolverHints` recorded, and + * therefore the spelling every stage below it — the gate included — was handed. + */ +async function dispatch(sessionUser: unknown, method: string, path: string, gateActive = true) { + const dispatcher = makeDispatcher(sessionUser, gateActive); + const context: any = { request: new Request(`http://localhost/api/v1${path}`) }; + const result = await dispatcher.dispatch(method, path, undefined, {}, context, '/api/v1'); + return { result, context }; +} + +/** The gate's 403 carries its `code` in the envelope's `details` (`error(msg, 403, { code })`). */ +const gateCodeOf = (result: any) => + result.response?.body?.error?.details?.code ?? result.response?.body?.error?.code; + +describe('[#17625] the API root resolves to the discovery route for a gated session', () => { + it('⭐ POSITIVE CONTROL — this fixture really does gate: a protected path answers 403 with the gate code', async () => { + // ⛔ Do not delete or weaken this. `enforceAuthGate` fails open on any + // hiccup, so without a request that the SAME fixture refuses, every + // 200 below is compatible with "the gate never ran". + const { result } = await dispatch(GATED_USER, 'GET', PROTECTED_PATH); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(403); + expect(gateCodeOf(result)).toBe('PASSWORD_EXPIRED'); + }); + + it('PIN 1 — `GET ${prefix}/` returns the discovery payload (was 403 after #7898)', async () => { + const { result } = await dispatch(GATED_USER, 'GET', '/'); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + // The discovery document itself, not merely "not a 403". + expect(result.response?.body?.data?.name).toBe('ObjectOS'); + expect(result.response?.body?.data?.routes).toBeDefined(); + }); + + it('PIN 2 — `GET ${prefix}` (no trailing slash) is unchanged: still the discovery payload', async () => { + const { result } = await dispatch(GATED_USER, 'GET', ''); + expect(result.handled).toBe(true); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.name).toBe('ObjectOS'); + expect(result.response?.body?.data?.routes).toBeDefined(); + }); + + it('serves the SAME document for both root spellings and for the named route', async () => { + // One route, three spellings — the property the canonicalisation buys. + const [slash, bare, named] = await Promise.all([ + dispatch(GATED_USER, 'GET', '/'), + dispatch(GATED_USER, 'GET', ''), + dispatch(GATED_USER, 'GET', '/discovery'), + ]); + for (const r of [slash, bare, named]) expect(r.result.response?.status).toBe(200); + expect(slash.result.response?.body?.data).toEqual(named.result.response?.body?.data); + expect(bare.result.response?.body?.data).toEqual(named.result.response?.body?.data); + }); + + it('⭐ THE MECHANISM — the gate is handed the allow-listed route NAME, never `""` or `"/"`', async () => { + // This is the assertion that makes the repair the RULED one rather than + // a coincidence: both root spellings are canonicalised BEFORE the gate, + // so what the gate evaluates is `/discovery` — a name `ALLOW_ROUTES` + // already carries. ⛔ If this ever reads `'/'`, the fix has regressed to + // the shape upstream measured insufficient, and PIN 1 would only still + // pass because something else started exempting the root. + for (const path of ['/', '']) { + const { context } = await dispatch(GATED_USER, 'GET', path); + expect(context.routePath, path).toBe('/discovery'); + } + // …and a path that is NOT the root is not rewritten. + const { context } = await dispatch(GATED_USER, 'GET', PROTECTED_PATH); + expect(context.routePath).toBe(PROTECTED_PATH); + }); + + it('⭐ NEGATIVE-DIRECTION CONTROL — the repair narrows nothing: an UNGATED session still reads the root', async () => { + for (const path of ['/', '', '/discovery']) { + const { result } = await dispatch(UNGATED_USER, 'GET', path); + expect(result.response?.status, path).toBe(200); + expect(result.response?.body?.data?.name, path).toBe('ObjectOS'); + } + }); + + it('the named `/discovery` route keeps answering for a gated session', async () => { + const { result } = await dispatch(GATED_USER, 'GET', '/discovery'); + expect(result.response?.status).toBe(200); + expect(result.response?.body?.data?.name).toBe('ObjectOS'); + }); +}); + +describe('[#17625] the boundary — what the canonicalisation deliberately does NOT move', () => { + it('the ENVIRONMENT-SCOPED root keeps its own answer: `${prefix}/environments/` is still gated', async () => { + // ⚠️ A different input class, and deliberately untouched. The gate runs + // BEFORE the scoped-URL strip, so this request is judged on + // `/environments/` — which matched no `ALLOW_ROUTES` entry before + // #7898 either, so its answer did not move in that card and must not + // move in this one. The `''` the strip produces afterwards is why the + // discovery branch keeps its `''` arm. + const { result } = await dispatch(GATED_USER, 'GET', '/environments/env-1'); + expect(result.response?.status).toBe(403); + expect(gateCodeOf(result)).toBe('PASSWORD_EXPIRED'); + }); + + it('a non-root path that merely LOOKS empty after the strip is not the root', async () => { + // `//` strips to `'/'`, not to `''`, so it is not canonicalised and is + // not exempt — recorded so a later reader does not widen the rule into + // "any number of trailing slashes is the root". + const { result, context } = await dispatch(GATED_USER, 'GET', '//'); + expect(context.routePath).toBe('/'); + expect(result.response?.status).toBe(403); + }); +}); diff --git a/packages/runtime/src/http-dispatcher.ts b/packages/runtime/src/http-dispatcher.ts index 9aa8794b8e..52e51268d7 100644 --- a/packages/runtime/src/http-dispatcher.ts +++ b/packages/runtime/src/http-dispatcher.ts @@ -246,6 +246,24 @@ function isPathWithinPrefix(path: string, prefix: string): boolean { return Number.isNaN(next) || next === 47 /* '/' */ || next === 63 /* '?' */; } +/** + * The protocol-standard discovery route, and the canonical spelling of the API + * root (#17625). + * + * Read by exactly two sites — the root canonicalisation at the top of + * `dispatch()` and the discovery branch that serves it — so "which route does + * the bare root resolve to" is answered once. ⛔ Never re-spell either site as + * a literal: the whole defect #17625 repairs was two places disagreeing about + * what the empty path meant, and a third disagreement is one edit away if the + * value is typed twice. + * + * It is also the string the ADR-0069 gate sees for a root request, which is + * why the value has to be the ALLOW-LISTED route name rather than `'/'`: + * `ALLOW_ROUTES` in `packages/core/src/security/auth-gate.ts` carries + * `['discovery']` and nothing that matches a segment-less path. + */ +const DISCOVERY_ROUTE = '/discovery'; + /** * `services.search`'s in-process remedy string (#7939), kept out of the * shared `inProcessServiceMessage('search')` path on purpose: that helper's @@ -2498,6 +2516,48 @@ export class HttpDispatcher { async dispatch(method: string, path: string, body: any, query: any, context: HttpProtocolContext, prefix?: string): Promise { let cleanPath = path.replace(/\/$/, ''); // Remove trailing slash if present, but strict on clean paths + // ── The API root IS the discovery route, under a second spelling ── + // [#17625, the runtime half of #7898's ruling A] The trailing-slash + // strip above collapses BOTH root spellings the dispatcher accepts — + // `${prefix}/` (arriving as `'/'`) and `${prefix}` (arriving as `''`, + // the MSW/base-URL-stripped form) — onto `''`. That empty string then + // travelled through every cross-cutting stage below as a path that + // names no route, and only the discovery branch at the foot of this + // method knew it meant the API root. The gate does not read that + // branch, so the two disagreed the moment `isAuthGateAllowlisted` + // stopped exempting a falsy path (#7898): a gated session's + // `GET ${prefix}/` answered 403 instead of the discovery payload. + // + // WHY NORMALISING TO `'/'` IS NOT THE FIX, measured rather than + // assumed. `isAuthGateAllowlisted('/')` is `false` — `'/'` has no + // segments, so no `ALLOW_ROUTES` entry can match it — and the + // discovery branch tests `'/discovery'` or `''`, which `'/'` satisfies + // neither. `'' → '/'` therefore RELOCATES the 403 rather than removing + // it; both legs are pinned upstream in + // `packages/core/src/security/auth-gate.test.ts` ("does not exempt the + // dispatcher bare-root `cleanPath` — step 2 is #17625"). + // + // So the root is canonicalised to the route it has always served + // instead, and the alias stops being a path that nothing recognises. + // ⛔ This is NOT a tolerance re-added to the allow-list: `packages/core` + // is untouched, `ALLOW_ROUTES` is unchanged, and the only input whose + // gate answer moves is the API root — which gains exactly the exemption + // `/discovery` already had, and gains it by BEING that route. Every + // other path, empty-but-not-root callers included, is unaffected: a + // caller that reaches the gate with no path at all is refused at the + // predicate, and that seam stays core's (`isAuthGateAllowlisted` + // fail-closed, `shouldDenyAnonymous` declaring the pathless case + // itself) — ⛔ not re-derived here. + // + // ⚠️ One spelling, read from one constant, deliberately: the branch + // below matches on `DISCOVERY_ROUTE` too, so the canonical form cannot + // drift from the route it canonicalises to. The branch keeps its `''` + // arm regardless — the scoped-URL strip further down re-creates `''` + // for `${prefix}/environments/`, which is a different input class, + // is gated on its own scoped spelling BEFORE the strip, and is not + // touched by this card. + if (cleanPath === '') cleanPath = DISCOVERY_ROUTE; + // ── Liveness carve-out — the ONE route family that runs no preamble ── // [#15910, maintainer ruling 2026-09-06 (decision batch #57), option C, // verbatim 「同意」] "Carve liveness out of the identity step. `/health` @@ -2635,9 +2695,19 @@ export class HttpDispatcher { } // 0. Discovery Endpoint (GET /discovery or GET /) - // Standard route: /discovery (protocol-compliant) - // Legacy route: / (empty path, for backward compatibility — MSW strips base URL) - if ((cleanPath === '/discovery' || cleanPath === '') && method === 'GET') { + // Standard route: /discovery (protocol-compliant) — and, since #17625, + // the spelling the API root arrives here as: `${prefix}` / `${prefix}/` + // are canonicalised to `DISCOVERY_ROUTE` at the top of `dispatch()`, so + // the root reaches this branch under the same name the ADR-0069 gate + // allow-lists instead of as an empty path only this branch understood. + // + // The `''` arm is still LIVE and ⛔ must not be deleted as dead: the + // scoped-URL strip above re-creates `''` from + // `${prefix}/environments/`, whose gate decision was already taken + // on its own scoped spelling before the strip ran. That is a different + // input class from the unscoped root and #17625 deliberately left it + // exactly as it was. + if ((cleanPath === DISCOVERY_ROUTE || cleanPath === '') && method === 'GET') { const info = await this.getDiscoveryInfo(prefix ?? '', context); return { handled: true, From 76d252079b988c4bb7cfdea75aff2e5d94ea11d2 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 13:47:16 +0000 Subject: [PATCH 2/3] docs(changeset): the API root is the discovery route (#17625) Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- .../17625-api-root-is-the-discovery-route.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .changeset/17625-api-root-is-the-discovery-route.md diff --git a/.changeset/17625-api-root-is-the-discovery-route.md b/.changeset/17625-api-root-is-the-discovery-route.md new file mode 100644 index 0000000000..a6370451cf --- /dev/null +++ b/.changeset/17625-api-root-is-the-discovery-route.md @@ -0,0 +1,63 @@ +--- +'@objectstack/runtime': patch +--- + +fix(runtime): the API root is the discovery route, under a second spelling — a gated session's `GET ${prefix}/` reaches discovery again (#17625) + +`HttpDispatcher.dispatch()` strips one trailing slash, so both root spellings it +accepts collapsed onto the empty string: `${prefix}/` arrives as `/` and +`${prefix}` arrives as `` (the MSW / base-URL-stripped form). Only the discovery +branch at the foot of the method knew that empty string meant the API root. The +ADR-0069 authentication-policy gate, which runs far above it, did not. + +That disagreement was invisible while `isAuthGateAllowlisted` answered `true` +for a falsy path. objectstack#7898 made the predicate fail-closed at the source +— exemption is now something a path EARNS by naming an allow-listed route — and +the bare-root discovery request started answering 403 for a session carrying an +`authGate` posture (expired password, required MFA): + +``` +FROM GET ${prefix}/ (session with user.authGate) -> 200 discovery document +TO GET ${prefix}/ (session with user.authGate) -> 403 PASSWORD_EXPIRED // regression +NOW GET ${prefix}/ (session with user.authGate) -> 200 discovery document +``` + +**Normalising the root to `/` is measured insufficient and is not what landed.** +`isAuthGateAllowlisted('/')` is `false` — a segment-less path matches no +`ALLOW_ROUTES` entry — and the discovery branch tests `/discovery` or the empty +string, neither of which `/` satisfies. `'' -> '/'` therefore relocates the 403 +rather than removing it. Both legs are pinned upstream in +`packages/core/src/security/auth-gate.test.ts` ("does not exempt the dispatcher +bare-root `cleanPath` — step 2 is #17625"). + +The root is canonicalised to `/discovery` instead — the route it has always +served — read from one constant by both the canonicalisation and the branch that +serves it, so the two cannot drift into a third disagreement about what the +empty path means. + +**⛔ No allow-list was widened and `packages/core` is untouched.** The only input +whose gate answer moves is the API root, and it gains exactly the exemption +`/discovery` already carried, by BEING that route — no new information is +reachable, since `/discovery` was already exempt and already outside the +project-membership skip check. A caller that reaches the gate with no path at +all is still refused at the predicate, and the pathless case stays declared +where it lives (`shouldDenyAnonymous`) rather than re-derived at this seam. + +**What does NOT change.** `${prefix}` with no trailing slash keeps serving the +same document; the named `/discovery` route is untouched; the +environment-scoped root `${prefix}/environments/` keeps its own answer, +which matched no allow-listed route before objectstack#7898 either. `//` strips +to `/`, not to the empty string, so it is not the root and is not canonicalised. + +**ADR-0087 disposition: no ledger entry is owed and no marker is required.** +This changeset declares no breaking change, which is the only condition under +which `check:adr-0087-registration` demands a disposition marker. On the +substance: no ADR-0087 shape surface moved — the diff touches one +`packages/runtime` transport file and its sibling test, no `*.zod.ts`, no +`packages/spec/**`, no `packages/spec/src/contracts/**` entry and no object +definition — so `objectstack migrate meta` has nothing to reach, and no +authorable metadata key, accept set or stored shape changes. Nor is this an +ADR-0087 conversion-layer entry: nothing lenient is being accepted from a +metadata producer. One transport's two spellings of its own route are being +reconciled to the route's own name, which is the opposite direction — a dialect +removed, not tolerated. From 6c31e741c9f3c856b078b729b5c27c3e65dfb574 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 11 Sep 2026 14:18:06 +0000 Subject: [PATCH 3/3] =?UTF-8?q?fix(changeset):=20grade=20the=20runtime=20b?= =?UTF-8?q?ump=20`minor`,=20the=20floor=20an=20affirmative=20clause=20?= =?UTF-8?q?=E2=91=A1=20sets=20(#17625)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `Check Changeset` failed the LEVEL AXIS on the previous head: a PR whose clause ② is declared affirmative must grade at least one package whose `packages/**/src/**` it moves at `minor` or above, and this changeset graded the only such package `patch`. The level is a mechanical floor, not an editorial reading of the act. The maintainer ruling of 2026-09-04 (decision batch #35, on #15294) is written out under "WHICH LEVEL" in the `Check Changeset` step: the commit type may raise a bump but never lower it below what the act requires. The act here re-admits an input class the merged tree refuses, on an authorisation surface, so the type stays `fix(runtime)` and only the level moves. The changeset now records that reasoning so a later reader does not re-grade it back down as a plain bug fix. ⛔ The declaration was not softened to fit the level, and neither the gate nor the workflow was touched. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01TSf4DV7ziu4V5j73e46b7c --- .../17625-api-root-is-the-discovery-route.md | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/.changeset/17625-api-root-is-the-discovery-route.md b/.changeset/17625-api-root-is-the-discovery-route.md index a6370451cf..ee2057d3c9 100644 --- a/.changeset/17625-api-root-is-the-discovery-route.md +++ b/.changeset/17625-api-root-is-the-discovery-route.md @@ -1,5 +1,5 @@ --- -'@objectstack/runtime': patch +'@objectstack/runtime': minor --- fix(runtime): the API root is the discovery route, under a second spelling — a gated session's `GET ${prefix}/` reaches discovery again (#17625) @@ -49,6 +49,19 @@ environment-scoped root `${prefix}/environments/` keeps its own answer, which matched no allow-listed route before objectstack#7898 either. `//` strips to `/`, not to the empty string, so it is not the root and is not canonicalised. +**Why `minor` on a change whose commit type is `fix`.** The two are independent +and the floor is mechanical, not editorial: this PR's clause ② is declared +affirmative, and the maintainer's ruling of 2026-09-04 (decision batch #35, on +objectstack#15294) puts an affirmative clause ② on a package whose +`packages/**/src/**` the diff moves at AT LEAST `minor` — *the commit type may +raise a bump but never lower it below what the act requires*, written out under +"WHICH LEVEL" in the `Check Changeset` step of +`.github/workflows/pr-automation.yml`. ⛔ So the reading that this is "a 403 that +should be a 200, therefore a patch" is an argument about INTENT and does not +reach the level: the act re-admits an input class the merged tree refuses, on an +authorisation surface, and that is what the level grades. The commit type stays +`fix(runtime)`, because the type describes the act and the level prices it. + **ADR-0087 disposition: no ledger entry is owed and no marker is required.** This changeset declares no breaking change, which is the only condition under which `check:adr-0087-registration` demands a disposition marker. On the