From cc8e8d4ed6ca665c9e99013fc95cdea2625a9ab9 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 10:45:46 -0700 Subject: [PATCH 1/9] docs(quest): drop suffix-based routing from the plans Maintainer decision: suffix-based routing is no longer planned. Rewrite the wildcard line, path patterns, processor, broadcast epoch, and cluster routing quests around prefix claims and the longest-prefix rule, and drop the relay cluster doc's promise of non-prefix pattern resolution. Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/cluster.md | 9 +- quest/m0/wildcard/README.md | 165 ++++++++++----------------- quest/m0/wildcard/demand.md | 22 ++-- quest/m0/wildcard/resolve.md | 51 ++++----- quest/m1/broadcast-epoch/README.md | 8 +- quest/m1/cluster-routing.md | 6 +- quest/m1/path-patterns.md | 10 +- quest/m1/processor/README.md | 8 +- quest/m1/processor/advertise-auth.md | 14 +-- 9 files changed, 122 insertions(+), 171 deletions(-) diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index b1eb6ddbe7..1ecc5a4530 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -43,18 +43,17 @@ link costs 1, which reproduces plain hop counting. Each relay adds the price of the link an announcement arrived on before forwarding it, so a route's cost is the sum of what it crossed. -Wildcard advertisements are forwarded and costed the same way as an exact-path +Prefix advertisements are forwarded and costed the same way as an exact-path route: each hop appends its identity, adds the link price, and passes the claim on. An advertisement must be contained by one of the publisher's granted -prefixes (`grant/**`); an over-wide pattern is refused rather than clamped. +prefixes (`grant/**`); an over-wide prefix is refused rather than clamped. -Routing prefers the most specific pattern, then a fully identified hop list +Routing prefers the longest covering prefix, then a fully identified hop list over one that holds a 0 (an anonymous hop) at any depth, then the lowest cost, then the shortest hop list, breaking any remaining tie toward the newest announcement so a reconnecting publisher isn't outranked by the session it replaced. An assigned identity for an anonymous peer is local selection state -and is never written into the hop list. Resolving a non-prefix pattern into a -subscription is not implemented yet. +and is never written into the hop list. ```toml [cluster] diff --git a/quest/m0/wildcard/README.md b/quest/m0/wildcard/README.md index 13795b6eff..73e58c3131 100644 --- a/quest/m0/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -5,16 +5,17 @@ A service claims the prefix it could serve rather than enumerating every broadcast under it; the client library filters that claim against the pattern interest the caller asked for, so nothing on the wire spells a -wildcard. A claim is priced at what starting the work would cost. Specificity wins first: a concrete -claim shadows a wildcard regardless of cost, and prices compete within the -same specificity tier. A terminal concrete refusal does not fall through to -a catch-all; its claim must be withdrawn. Retracting a wildcard stops new -work without shedding what is already running. - -Three workloads need this, and they are the three pattern shapes. A transcode +wildcard. A claim is priced at what starting the work would cost. The longest +covering prefix wins first: a concrete announcement shadows a broader claim +regardless of cost, and prices compete only among claims of the same prefix. +A terminal concrete refusal does not fall through to a catch-all; its claim +must be withdrawn. Retracting a claim stops new work without shedding what is +already running. + +Three workloads need this. A transcode worker today announces a standby derivative for every matching live broadcast, -so announcements scale as workers times broadcasts; with a suffix pattern -`**/transcode.pro` it advertises once for the whole fleet. A chat backend +so announcements scale as workers times broadcasts; claiming one service +prefix, it advertises once for the whole fleet. A chat backend cannot enumerate at all: rooms exist independently of any broadcast, and the subtree pattern `/chat/**` expresses them. An archive serving recordings over FETCH wants to say "if nobody is publishing this live, I have it", which @@ -33,11 +34,12 @@ across the fleet in resident memory. Decided in [#3770](https://github.com/moq-dev/moq/pull/3770): publishing is prefix-only on every wire and patterns never leave the token or the client library. `dynamic(prefix, route)` -advertises a prefix; a suffix or catch-all claim is expressed as the -widest prefix that covers it (`**` is the root) and the request is the -authority, so the advertise half of this questline is re-scoped to prefix -claims resolved against pattern interest. The three workloads above still -hold: the transcoder claims the root and refuses what it will not serve. +advertises a prefix; the catch-all claim is the root prefix, and the request +is the authority, so the advertise half of this questline is re-scoped to +prefix claims resolved against pattern interest. The three workloads above +still hold: the transcoder claims its service prefix +([Where derived output lives](#where-derived-output-lives)), and the archive +claims the root and refuses what it does not have. Resolve and Demand are additive and land on main. ### What already exists, and what does not @@ -66,11 +68,11 @@ announced it and cached per prefix in `ServeState.served` (`:764`). That is the lookup the old `origin::Dynamic` could not provide, and it is what [Resolve](/quest/m0/wildcard/resolve.md) now extends rather than replaces. -Request resolution, by contrast, is still prefix-only (`best_server` in -`rs/moq-net/src/model/origin.rs`). The pattern matcher itself exists: -`moq_net::{Pattern, Patterns, Segment}` and `Path.Pattern` / +Request resolution is prefix-only (`best_server` in +`rs/moq-net/src/model/origin.rs`) and stays that way. The pattern matcher +itself exists: `moq_net::{Pattern, Patterns, Segment}` and `Path.Pattern` / `Path.Patterns` in `js/net/src/path.ts` own the shared matching, containment, -specificity, and rebasing advertisements reuse. +specificity, and rebasing tokens and filters reuse. What is genuinely missing, beyond patterns themselves, is content identity. Announcement `Epoch` was specified into lite-06 by @@ -88,40 +90,37 @@ field. dialect is what tokens and the consume-side filter use, matched by the shared matcher, so nothing resembles a second grammar and nothing on the wire spells a wildcard. -- **Most specific pattern wins, and its refusal is final.** This is the rule +- **Longest prefix wins, and its refusal is final.** This is the rule routing already follows: `best_server` filters to the longest covering prefix before it compares cost, and the lite draft says the same, matching - longest-prefix-match wherever it appears. When several patterns match one - path, only the tier selected by the matcher's shared structural specificity is - consulted; equal-specificity patterns - form one pool that cost and the request hash order. A terminal refusal from - the winning tier IS the answer and never falls through to a less specific - pattern, so a transcoder refusing a path does not leak the request to the - archive's catch-all, and one unserved path still costs one round trip. The - capacity re-resolution below stays within the tier, refuser excluded. The - accepted consequence: an offline derivative (a recording of - `foo.hang/transcode.pro`) is not reachable through the catch-all, because - the more specific transcode pattern shadows it. -- **A wildcard is a POOL, not a competitor.** Several advertisers of one - pattern is the normal state, not a hazard: every transcode worker advertises - `**/transcode.pro` and takes a share. What distributes them is a + longest-prefix-match wherever it appears. Advertisers of one prefix form one + pool that cost and the request hash order. A terminal refusal from the + winning tier IS the answer and never falls through to a shorter prefix, so a + transcoder refusing a path does not leak the request to the archive's + catch-all, and one unserved path still costs one round trip. The capacity + re-resolution below stays within the tier, refuser excluded. The accepted + consequence: an offline derivative is not reachable through the catch-all + while a longer prefix covers it. +- **A claim is a POOL, not a competitor.** Several advertisers of one + prefix is the normal state, not a hazard: every transcode worker claims the + same prefix and takes a share. What distributes them is a deterministic hash of the REQUESTED path against each advertiser, so distinct - paths spread rather than one advertiser winning the whole pattern. + paths spread rather than one advertiser winning the whole prefix. Distribution is the requirement, not any particular pair: a correct hash may legitimately rank the same advertiser first for two given paths, so what must hold is that a large path set spreads and that one path always resolves the same way. Cost orders the pool first, which keeps work local and makes a distant advertiser the overflow rather than an equal peer. -- **A wildcard is priced, not special-cased.** Within a tier, route selection - stays one comparison on one metric. Concrete-versus-wildcard is not decided - by price at all: a concrete claim is maximally specific, so "most specific - wins" above already shadows every pattern behind it at any cost. The +- **A claim is priced, not special-cased.** Within a tier, route selection + stays one comparison on one metric. Concrete-versus-claim is not decided + by price at all: a concrete announcement is the longest prefix, so "longest + prefix wins" above already shadows every claim behind it at any cost. The accepted consequence follows from that rule's finality: a live session's - concrete claim shadows a healthy wildcard pool even when its service is + concrete claim shadows a healthy pool even when its service is broken, its terminal refusal does not fall through, and the shadow lasts exactly as long as the claiming session that carries it. - The seed still has a floor, because standby and running claims of equal - specificity do meet: a standby concrete claim (`with_cost(1000)` is the + The seed still has a floor, because standby and running claims of the same + prefix do meet: a standby concrete claim (`with_cost(1000)` is the existing per-broadcast convention) shares a tier with a running publisher's concrete announcement and with warm-advertise's exact-path warm routes. The floor MUST exceed the deployment's enforced maximum charged-link count @@ -141,10 +140,9 @@ field. path is denied. This is why an over-claiming advertisement is not a defect: the catch-all `**` is legal, and answering "not that one" is the mechanism. - **Containment against the publish scope is what authorization checks.** An - advertised pattern MUST be contained by the sender's granted patterns (the - matcher's containment check). This handles literal-headed and leading-star - patterns identically and refuses any attempted widening rather than clamping - it. Fleet-wide services use the cluster identity; a customer service may + advertised prefix MUST be contained by the sender's granted patterns (the + matcher's containment check), and any attempted widening is refused rather + than clamped. Fleet-wide services use the cluster identity; a customer service may advertise only the exact set its own v1 grant contains. - **Wildcards are visible to subscribers.** A subscriber sees every pattern matching under its scope, rebased by the matcher's exact set-valued operation, @@ -198,63 +196,18 @@ field. ### Where derived output lives -Suffix matching lets a contribution be published where it is addressed, a -descendant of its source. This is the moq.pro (downstream) deployment shape, -and it is what the suffix pattern form exists for: - -```text -pid/foo.hang source -pid/foo.hang/catalog.pro combined catalog, edge-composed -pid/foo.hang/transcode.pro the transcode contribution -pid/foo.hang/transcribe.pro the transcription contribution -``` - -The `.pro` segment suffix is both the routed pattern and the platform-output -marker: `**/transcode.pro` routes every project's transcode demand to the -worker pool, and a segment ending in `.pro` is the one predicate every source -rule matcher excludes, so platform output is never recursively transcoded or -recorded. - -The rejected alternative publishes contributions at mirrored paths in reserved -namespaces (`.transcode//...`) hidden by an origin-consumer overlay, -because prefix-only matching needs the variable part of a path trailing. That -overlay is not a view transform: `pid/foo` and `.transcode/pid/foo` are -separate tree leaves with separate broadcast fronts, so it has to build a -logical front across roots that re-owns route selection, content identity, the -split-horizon guard, and splicing. The suffix pattern needs none of it while -keeping what the mirror buys: - -- **The grant needs no transform.** The customer addresses - `foo.hang/transcode.pro`, a descendant of `foo.hang`, so an existing grant - covers it by ordinary segment-aware prefix. No companion-grant rule, no - atomic `.pro/` scope, and nothing minted differently, which matters because - customer-issued tokens are minted by integrations the platform does not - control. -- **Metering is untouched.** The published path is rooted at `pid`, so the - platform's egress metering sees the customer path with no special case at - all. -- **The wildcard is fleet-wide.** The suffix is project-agnostic, so a worker - advertises once for every project rather than once per project, which is what - removes project discovery entirely. -- **Takeover is single-front.** A worker's concrete announcement lands at the - literal path the wildcard served, so wildcard-versus-concrete and - worker-versus-worker collisions are ordinary route selection at one tree node, - not a cross-root front. - -What the mirror buys and this deliberately gives up: a customer holding -`publish: ["pid/"]` CAN publish `foo.hang/transcode.pro` themselves, competing -with or forging platform output. Both then resolve at one path, cost decides, -and a live customer broadcast beats the worker's standby seed. That is confined -to their own namespace, self-sabotage of their own catalog, never another -project's, and is cheaper to allow and document than a reserved-name registry -or a token transform. The mirror's SUBSCRIBE-only overlay asymmetry existed to -prevent exactly this and goes with it. +A prefix claim needs the variable part of a path trailing, so a fleet-wide +service claims its own prefix and mirrors the source path beneath it +(`.transcode//foo.hang`) rather than publishing beneath the source. The +source's catalog reaches the contribution through a cross-broadcast reference. +The platform layout, grants, and metering are the deployment's; moq.pro's is in +its [wildcard questline](https://github.com/moq-dev/moq.pro/blob/main/quest/m2/wildcard/README.md). -The archive is the same shape at the source path itself: a recording IS the -broadcast, served from storage through the catch-all pattern. A wildcard names -no generation, so a client that must distinguish recording generations reads -the catalog's archive entry ([archive](/quest/m1/archive/README.md)) rather -than announce state. +The archive serves the source path itself: a recording IS the broadcast, +served from storage through the root claim, and a live publisher's concrete +announcement shadows it. A claim names no generation, so a client that must +distinguish recording generations reads the catalog's archive entry +([archive](/quest/m1/archive/README.md)) rather than announce state. ## Quests @@ -267,10 +220,10 @@ than announce state. ## Related - [path-patterns](/quest/m1/path-patterns.md) - owns the pattern dialect - and the shared matcher advertisements reuse -- [archive](/quest/m1/archive/README.md) - an archive advertises the catch-all - pattern, and its catalog names the generations a wildcard cannot + and the shared matcher tokens and filters reuse +- [archive](/quest/m1/archive/README.md) - an archive claims the root, and its + catalog names the generations a claim cannot - [pop-skipping](/quest/m1/pop-skipping/README.md) - it owns the route cost and the rank hash this reuses -- [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - derived output moves under the - source's `@` segment, which the suffix patterns still match +- [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - derived output + mirrors the source path, `@` segment included diff --git a/quest/m0/wildcard/demand.md b/quest/m0/wildcard/demand.md index 80be0b35ed..b373b2a2d2 100644 --- a/quest/m0/wildcard/demand.md +++ b/quest/m0/wildcard/demand.md @@ -13,29 +13,27 @@ The gate is JS-only, and it is already prefix-aware. [moq#3225](https://github.com/moq-dev/moq/pull/3225) made `#isPathAnnounced` (`js/watch/src/broadcast.ts:216`) hold the set of announced prefixes and accept any that covers the path, so a route at `room/` already makes -`room/alice/cam.hang` selectable without naming it. What it cannot do is match -a pattern, since it tests with `Path.hasPrefix` (`:223`). - -So the remaining work is narrow: teach the JS client the wildcard -advertisement (`js/net/src/announced.ts` and `js/net/src/lite/announce.ts`, -mirroring the moq-net wildcard advertisement) -and make the covering test use `Path.Pattern` (`js/net/src/path.ts:526`) -rather than prefix containment. -Withdrawal of the last covering wildcard hides the rendition again, the same +`room/alice/cam.hang` selectable without naming it. Claims are prefixes too, so +the covering test stays `Path.hasPrefix` (`:223`) and needs no pattern +matching. + +So the remaining work is narrow: prove that a rendition covered only by a +service's prefix claim is listed and demanded, and fix whatever stops it. +Withdrawal of the last covering claim hides the rendition again, the same reactive shape announcements have today. Do not simply delete the gate. It exists so the player does not subscribe to absent broadcasts and so renditions appear and disappear reactively with announcements. The Rust side needs nothing here: `moq-mux::Source` resolves references through `request_broadcast`, which -[resolve](/quest/m0/wildcard/resolve.md) teaches to consult patterns. +[resolve](/quest/m0/wildcard/resolve.md) teaches to consult prefix claims. Two existing soft spots to not reintroduce: the first evaluation runs before the announcement stream has populated, briefly hiding cross-broadcast renditions on startup; and a token without announce visibility over the sibling's path hides it permanently even though a direct subscribe would work. -A covering wildcard fixes the second only if patterns are forwarded under the -subscriber's scope, which advertise's rebasing rule guarantees. +A covering claim fixes the second only if it is visible under the +subscriber's scope. Tests: a rendition whose broadcast is covered only by a wildcard is listed and playable, subscribing it is what starts production (the subscribe arrives diff --git a/quest/m0/wildcard/resolve.md b/quest/m0/wildcard/resolve.md index c582df54ad..09579cfacc 100644 --- a/quest/m0/wildcard/resolve.md +++ b/quest/m0/wildcard/resolve.md @@ -2,14 +2,15 @@ ## Goal -A relay resolves a subscribe or FETCH for an unannounced path against the best -matching wildcard. +A relay resolves a subscribe or FETCH for an unannounced path against the +longest covering prefix claim. ## Plan -Specificity before cost is an explicit routing policy: a catch-all must not -silently take over a path still claimed by a concrete service, even when that -service refuses the request. Keep the draft and regressions aligned with it. +Longest prefix before cost is an explicit routing policy: a catch-all must not +silently take over a path still claimed by a longer prefix or a concrete +announcement, even when that claim refuses the request. Keep the draft and +regressions aligned with it. [moq#3225](https://github.com/moq-dev/moq/pull/3225) built the table this quest needs. `Consumer::request_broadcast` resolves a local broadcast first, then @@ -22,23 +23,21 @@ repeat requests share one upstream subscription. A route never passes through `origin::Dynamic`'s shared FIFO, so requester identity and the hop chain are both available to selection. -So this quest extends a working table rather than standing one up: teach the -route entries to hold a pattern instead of only a literal prefix, and teach -selection the tiering and pooling below. Keep the exclusion filter where it is, -applied before selection, so an out-of-band request can never be served back -through the peer that made it. +So this quest extends a working table rather than standing one up: route +entries stay literal prefixes, and selection learns the pooling below. Keep the +exclusion filter where it is, applied before selection, so an out-of-band +request can never be served back through the peer that made it. -Among the survivors, only the tier selected by the matcher's shared structural -specificity is consulted, with equal-specificity patterns forming one pool. A -refusal from that tier never falls through to a less -specific one, so `**/transcode.pro` shadows the archive's `**` for -every transcode path, matched or refused. Selection within the tier is lowest -accumulated cost first, then a hash of the REQUESTED path against each -advertiser's origin id. Keying on the request rather than the pattern is the -whole point: hashing the pattern would hand one advertiser every path matching -it. A concrete announcement is maximally specific and shadows every wildcard -regardless of cost. A terminal refusal from that concrete claim never falls -through to a wildcard; it shadows until the claim is withdrawn. +Among the survivors, only the longest covering prefix is consulted, and its +advertisers form one pool. A refusal from that tier never falls through to a +shorter prefix, so a transcode service's prefix shadows the archive's root +claim for every path beneath it, matched or refused. Selection within the tier +is lowest accumulated cost first, then a hash of the REQUESTED path against +each advertiser's origin id. Keying on the request rather than the prefix is +the whole point: hashing the prefix would hand one advertiser every path +beneath it. A concrete announcement is the longest prefix and shadows every +claim regardless of cost. A terminal refusal from that concrete claim never +falls through to a broader one; it shadows until the claim is withdrawn. Both lookup kinds route through this table: subscribe via `recv_subscribe`'s existing fallback, and FETCH the same way, since the archive's whole use case @@ -50,9 +49,9 @@ A served path is not announced downstream, so a wildcard never manufactures announcements. But a resolved upstream SUBSCRIPTION must be installed as a ROUTE on the path's origin node, seeded with the wildcard's accumulated cost, not parked in the request-level `served` cache alone. Preserve the route's -wildcard provenance and specificity: installing an exact-path node must not +claim provenance and prefix: installing an exact-path node must not promote it into a concrete announcement. A concrete announcement arriving -later lands on the same node and wins by specificity, and the front's +later lands on the same node and wins as the longest prefix, and the front's ordinary route change moves consumers at a group boundary. A cache-only answer would strand every bound consumer on the wildcard subscription with nothing able to migrate or stop it. When the concrete claim @@ -97,9 +96,9 @@ Tests, at the process level with real sessions rather than an in-process stand-i advertiser, or unroutable, but never hung and never looping. - A permanent refusal does not re-resolve, so a request for a path nobody serves costs exactly one round trip. -- A path matched by both a suffix pattern and the catch-all resolves against - the suffix pattern's pool only, and a terminal refusal from it never reaches - the catch-all advertiser. +- A path covered by both a service prefix and the root claim resolves against + the service prefix's pool only, and a terminal refusal from it never reaches + the root advertiser. - A refused subscribe resets rather than hanging, and leaves no state behind. - A wildcard retracted mid-serve does not disturb the subscription already running. diff --git a/quest/m1/broadcast-epoch/README.md b/quest/m1/broadcast-epoch/README.md index 0c40773fe3..5067a78064 100644 --- a/quest/m1/broadcast-epoch/README.md +++ b/quest/m1/broadcast-epoch/README.md @@ -40,10 +40,10 @@ Decided: bare `foo`, since a route covers its descendants, not its parent. A bare-name viewer behind one needs a publisher that opts out with the raw prefix route. Document this rather than promise it works. -- Derived output lives under the epoch it came from - (`pid/foo.hang/@e/transcode.pro`), so nested epochs must parse. This moves - the [wildcard](/quest/m0/wildcard/README.md) line's derived-output example - down one segment, and its suffix patterns still match. +- Derived output mirrors the epoch it came from + (`.transcode/pid/foo.hang/@e`, per the + [wildcard](/quest/m0/wildcard/README.md) line's derived-output layout), so + the service's prefix claim still covers it. This README owns: diff --git a/quest/m1/cluster-routing.md b/quest/m1/cluster-routing.md index 13d66d28a8..939bbdc442 100644 --- a/quest/m1/cluster-routing.md +++ b/quest/m1/cluster-routing.md @@ -48,8 +48,8 @@ make MoQ's common case. origin id, and forwards along its shortest path. Distance compares cost, then hop count, so every hop strictly shortens it even across `?cost=0` links. That is a shortest path to a virtual node linked to every origin, so - it is loop-free whenever relays agree on the topology. Specificity still - ranks first, per [Wildcard](/quest/m0/wildcard/README.md). + it is loop-free whenever relays agree on the topology. The longest covering + prefix still ranks first, per [Wildcard](/quest/m0/wildcard/README.md). - The first relay's choice rides the SUBSCRIBE, and transit relays forward toward that origin by topology alone, never re-selecting. Re-selection against another existence view loops: a relay that lost a specific claim @@ -113,4 +113,4 @@ make MoQ's common case. - [Skip unchanged announce updates](/quest/m0/announce-update-dedupe.md) - cuts duplicate updates on today's routing - [Local origin](/quest/m0/local-origin.md) - workers stop reading hop chains before they go -- [Wildcard](/quest/m0/wildcard/README.md) - the specificity, pool spread, and reply identity this selection builds on +- [Wildcard](/quest/m0/wildcard/README.md) - the longest-prefix rule, pool spread, and reply identity this selection builds on diff --git a/quest/m1/path-patterns.md b/quest/m1/path-patterns.md index 90b43fb304..691bcf1dc3 100644 --- a/quest/m1/path-patterns.md +++ b/quest/m1/path-patterns.md @@ -3,9 +3,9 @@ ## Goal Every predicate over a MoQ broadcast path uses one matcher. Tokens, -origin scopes, announce interests, public access rules, and wildcard -advertisements can express `pid/*/chat` and `**/transcode.pro` without -maintaining competing glob dialects. +origin scopes, announce interests, and public access rules can express +`pid/*/chat` and `**/*.hang` without maintaining competing glob dialects. +Routing is not a predicate here: advertisements stay prefixes. Literal paths remain coordinates, not sets. Roots, joins, exact broadcast names, URL paths, filesystem paths, and object-store keys keep their own @@ -98,5 +98,5 @@ matches, containment refusal, and old-version behavior. ## Related -- [Wildcard advertisements](/quest/m0/wildcard/README.md) - routing adopts the - matcher while retaining its own cost, pool, refusal, and resolution work +- [Wildcard advertisements](/quest/m0/wildcard/README.md) - routes on prefix + claims; the matcher only filters them against consume-side interest diff --git a/quest/m1/processor/README.md b/quest/m1/processor/README.md index dcc625f468..706bb702ae 100644 --- a/quest/m1/processor/README.md +++ b/quest/m1/processor/README.md @@ -4,7 +4,9 @@ A customer runs a worker in its own environment, connects outbound to a MoQ deployment, reads only eligible source media, and publishes an on-demand -contribution at `/.pro`. The platform supplies +contribution under a prefix it claims, mirroring the source path (the +[wildcard](/quest/m0/wildcard/README.md) line's derived-output layout). The +platform supplies registration, scoped credentials, routing, demand, status, and usage visibility; it does not upload or execute customer code. @@ -20,8 +22,8 @@ and custom transforms use the same worker lifecycle. contribution references, source relations, and correlation in the Hang catalog - [Advertise-only authorization](/quest/m1/processor/advertise-auth.md) - a - worker may advertise its contribution suffix without receiving permission to - publish arbitrary matching paths + worker may advertise its contribution prefix without receiving permission to + publish arbitrary paths beneath it - [Expiring media grants](/quest/m1/processor/grant-lease.md) - enforce short-lived exact grants on already-open consumer and producer handles diff --git a/quest/m1/processor/advertise-auth.md b/quest/m1/processor/advertise-auth.md index 26c1d8faaa..a7f0575802 100644 --- a/quest/m1/processor/advertise-auth.md +++ b/quest/m1/processor/advertise-auth.md @@ -2,15 +2,15 @@ ## Goal -A v1 worker credential can advertise an allowed wildcard without receiving -permission to publish any path matching it. Relays enforce advertise and +A v1 worker credential can advertise an allowed prefix claim without receiving +permission to publish any path beneath it. Relays enforce advertise and publish as independent capabilities before external processor credentials are minted. ## Plan Add an explicit advertise pattern union to the v1 claims, token SDKs, origin -scope, and relay authorization model. Wildcard authorization checks that +scope, and relay authorization model. A prefix claim is checked against that scope rather than borrowing the publish union. A concrete announcement or publish request still requires publish permission, so an advertise-only worker cannot bypass the demand exchange. @@ -21,7 +21,7 @@ the new v1 claim separates the capabilities. Land the claims, SDK, origin-scope, relay authorization, and tests without combining the release or the moq.pro (downstream) pin rollout into this quest. -Cover containment, rebasing, leading-star and suffix patterns, missing versus -empty advertise scope, v0 compatibility, token revalidation, concrete announce, -publish, FETCH, and a wildcard demand that receives only an exact short-lived -publish grant. +Cover containment of a claimed prefix in the advertise scope, rebasing, +missing versus empty advertise scope, v0 compatibility, token revalidation, +concrete announce, publish, FETCH, and a claim's demand that receives only an +exact short-lived publish grant. From e4a4e4238048d7e153bb8be8329d63186dc66a3c Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 12:08:49 -0700 Subject: [PATCH 2/9] docs: stop promising pattern advertisements Announcements carry a path prefix since #3770, so the wildcard quest and the pattern crate docs no longer list wildcard advertisements as a pattern consumer. Co-Authored-By: Claude Opus 5.5 --- js/pattern/README.md | 2 +- quest/m0/wildcard/README.md | 7 +++---- rs/moq-net/src/path/mod.rs | 6 +++--- rs/moq-pattern/README.md | 2 +- 4 files changed, 8 insertions(+), 9 deletions(-) diff --git a/js/pattern/README.md b/js/pattern/README.md index 61a687d3fc..48f9f3c12a 100644 --- a/js/pattern/README.md +++ b/js/pattern/README.md @@ -10,7 +10,7 @@ Exact path patterns for [Media over QUIC](https://moq.dev): grammar, matching, and set algebra. A pattern describes a set of broadcast paths. This package provides a shared grammar -for tokens, origin scopes, announce interests, and wildcard advertisements. Integrating +for tokens, origin scopes, and announce interests. Integrating patterns into those consumers is separate work. Literal paths stay coordinates; `@moq/net`'s path module keeps construction, joins, and prefix operations. diff --git a/quest/m0/wildcard/README.md b/quest/m0/wildcard/README.md index 73e58c3131..7b52f9a174 100644 --- a/quest/m0/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -55,10 +55,9 @@ same exact containment check. `Cost { warm, cold }` [#2925](https://github.com/moq-dev/moq/pull/2925). [moq#3225](https://github.com/moq-dev/moq/pull/3225) moved a long way toward -this. An announcement carries a `Pattern` covering a set of paths. Rust -`announce::Update.pattern` and TypeScript `Announce.Update.pattern` use the -matcher directly, so callers explicitly select prefix-shaped claims when -they need a concrete broadcast path. +this, and #3770 settled the wire: an announcement carries a path prefix on +every protocol, and a consumer filters announced paths against its pattern +interest locally. The routing table exists too. `Consumer::request_broadcast` resolves a local broadcast first, then `best_server`: the longest covering prefix, filtered by diff --git a/rs/moq-net/src/path/mod.rs b/rs/moq-net/src/path/mod.rs index 21acabb44e..ce3f0c930c 100644 --- a/rs/moq-net/src/path/mod.rs +++ b/rs/moq-net/src/path/mod.rs @@ -4,9 +4,9 @@ //! segment-aware prefix operations. [`Pattern`] describes a set of paths with //! wildcards, and [`Patterns`] is a union of them reduced by containment. The //! grammar and algebra live in [`moq-pattern`](moq_pattern); this module -//! re-exports them beside [`Path`] so grants, origin scopes, announce interests, -//! and wildcard advertisements can share one dialect. Literal path construction -//! and wire decoding retain their existing behavior. +//! re-exports them beside [`Path`] so grants, origin scopes, and announce +//! interests can share one dialect. Literal path construction and wire +//! decoding retain their existing behavior. pub use moq_pattern::{InvalidPattern, Pattern, Patterns, Segment, Specificity}; diff --git a/rs/moq-pattern/README.md b/rs/moq-pattern/README.md index bcaeddbcf4..5053dbf492 100644 --- a/rs/moq-pattern/README.md +++ b/rs/moq-pattern/README.md @@ -7,7 +7,7 @@ Exact path patterns for [Media over QUIC](https://moq.dev): grammar, matching, and set algebra. A pattern describes a set of broadcast paths. This crate provides a shared grammar -for tokens, origin scopes, announce interests, and wildcard advertisements. Integrating +for tokens, origin scopes, and announce interests. Integrating patterns into those consumers is separate work. Literal paths stay coordinates; `moq-net`'s `Path` and `@moq/net`'s path module keep construction, joins, and prefix operations. From 73859d06538b0827c88f9593bd2c1984c9670274 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 12:12:44 -0700 Subject: [PATCH 3/9] docs(quest): the player's covering check opts into hidden routes A `.transcode/` service prefix is hidden from default discovery, so the Demand check must ask for hidden routes to see the claim covering a rendition. Co-Authored-By: Claude Opus 5.5 --- quest/m0/wildcard/README.md | 3 ++- quest/m0/wildcard/demand.md | 7 +++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/quest/m0/wildcard/README.md b/quest/m0/wildcard/README.md index 7b52f9a174..fa227cc6e6 100644 --- a/quest/m0/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -199,7 +199,8 @@ A prefix claim needs the variable part of a path trailing, so a fleet-wide service claims its own prefix and mirrors the source path beneath it (`.transcode//foo.hang`) rather than publishing beneath the source. The source's catalog reaches the contribution through a cross-broadcast reference. -The platform layout, grants, and metering are the deployment's; moq.pro's is in +The `.` keeps the claim out of default listings, so the player's covering +check opts into hidden routes ([Demand](/quest/m0/wildcard/demand.md)). The platform layout, grants, and metering are the deployment's; moq.pro's is in its [wildcard questline](https://github.com/moq-dev/moq.pro/blob/main/quest/m2/wildcard/README.md). The archive serves the source path itself: a recording IS the broadcast, diff --git a/quest/m0/wildcard/demand.md b/quest/m0/wildcard/demand.md index b373b2a2d2..65fb880d2e 100644 --- a/quest/m0/wildcard/demand.md +++ b/quest/m0/wildcard/demand.md @@ -17,6 +17,13 @@ any that covers the path, so a route at `room/` already makes the covering test stays `Path.hasPrefix` (`:223`) and needs no pattern matching. +The check opts into hidden routes (`announced({ hidden: true })`). A service +prefix like `.transcode/` is hidden from default discovery, so without the opt-in +a subscriber with a broad scope never sees the claim covering a rendition. +Hiding only narrows listings, and this check lists nothing to the user, so +opting in is safe. It still relies on the relay and the token's scope exposing +those routes to the browser. + So the remaining work is narrow: prove that a rendition covered only by a service's prefix claim is listed and demanded, and fix whatever stops it. Withdrawal of the last covering claim hides the rendition again, the same From 8179d384331e53a0f18dba225aead61f2fdc0092 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 12:26:23 -0700 Subject: [PATCH 4/9] docs(relay): describe overlap authorization for prefix claims `Announcing::new` accepts a claim that overlaps the grant, and the route entry only serves requests the grant matches, so a wider claim is not refused. Co-Authored-By: Claude Opus 5.5 --- doc/bin/relay/cluster.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/doc/bin/relay/cluster.md b/doc/bin/relay/cluster.md index 1ecc5a4530..c55f36a19e 100644 --- a/doc/bin/relay/cluster.md +++ b/doc/bin/relay/cluster.md @@ -45,8 +45,9 @@ the sum of what it crossed. Prefix advertisements are forwarded and costed the same way as an exact-path route: each hop appends its identity, adds the link price, and passes the -claim on. An advertisement must be contained by one of the publisher's granted -prefixes (`grant/**`); an over-wide prefix is refused rather than clamped. +claim on. An advertised prefix must overlap the publisher's grant, or it is +refused. A prefix wider than the grant is accepted, but it only routes requests +for paths the grant covers. Routing prefers the longest covering prefix, then a fully identified hop list over one that holds a 0 (an anonymous hop) at any depth, then the lowest cost, From f6b0909e0c0ee825004df3b5e73be48baced6ed5 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 12:49:24 -0700 Subject: [PATCH 5/9] docs(quest): derived output lives under the hidden .pro prefix Mirror derived output under `.pro//`, moq.pro's convention. The prefix is hidden so customers on moq-lite-06 or older never see it; the player's covering check sees it only on lite-07, and an explicit subscribe works on any version. Co-Authored-By: Claude Opus 5.5 --- quest/m0/wildcard/README.md | 18 +++++++++++++----- quest/m0/wildcard/demand.md | 15 +++++++++------ quest/m1/broadcast-epoch/README.md | 2 +- 3 files changed, 23 insertions(+), 12 deletions(-) diff --git a/quest/m0/wildcard/README.md b/quest/m0/wildcard/README.md index fa227cc6e6..9b7b1e8569 100644 --- a/quest/m0/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -197,11 +197,19 @@ field. A prefix claim needs the variable part of a path trailing, so a fleet-wide service claims its own prefix and mirrors the source path beneath it -(`.transcode//foo.hang`) rather than publishing beneath the source. The -source's catalog reaches the contribution through a cross-broadcast reference. -The `.` keeps the claim out of default listings, so the player's covering -check opts into hidden routes ([Demand](/quest/m0/wildcard/demand.md)). The platform layout, grants, and metering are the deployment's; moq.pro's is in -its [wildcard questline](https://github.com/moq-dev/moq.pro/blob/main/quest/m2/wildcard/README.md). +(`.pro/transcode//foo.hang`, moq.pro's convention) rather than publishing +beneath the source. The source's catalog reaches the contribution through a +cross-broadcast reference. + +The leading `.` is deliberate. Existing customers on moq-lite-06 or older must +never see `.pro/` broadcasts, which could confuse their business logic. Those +versions cannot opt into hidden routes, so the relay never announces them +there. Hidden routes are a moq-lite-07 feature, so the player's covering check +opts into them ([Demand](/quest/m0/wildcard/demand.md)) and sees a claim only +when lite-07 is negotiated. A customer who wants transcodes upgrades, or +subscribes to the explicit `.pro//...` path, which works on any +version. Grants and metering are the deployment's; moq.pro's are in its +[wildcard questline](https://github.com/moq-dev/moq.pro/blob/main/quest/m2/wildcard/README.md). The archive serves the source path itself: a recording IS the broadcast, served from storage through the root claim, and a live publisher's concrete diff --git a/quest/m0/wildcard/demand.md b/quest/m0/wildcard/demand.md index 65fb880d2e..71567feaad 100644 --- a/quest/m0/wildcard/demand.md +++ b/quest/m0/wildcard/demand.md @@ -17,12 +17,15 @@ any that covers the path, so a route at `room/` already makes the covering test stays `Path.hasPrefix` (`:223`) and needs no pattern matching. -The check opts into hidden routes (`announced({ hidden: true })`). A service -prefix like `.transcode/` is hidden from default discovery, so without the opt-in -a subscriber with a broad scope never sees the claim covering a rendition. -Hiding only narrows listings, and this check lists nothing to the user, so -opting in is safe. It still relies on the relay and the token's scope exposing -those routes to the browser. +The check opts into hidden routes (`announced({ hidden: true })`). Derived +output lives under a hidden service prefix (`.pro/transcode/`), so without the +opt-in a subscriber with a broad scope never sees the claim covering a +rendition. Hiding only narrows listings, and this check lists nothing to the +user, so opting in is safe. Hidden routes are a moq-lite-07 feature: on +moq-lite-06 or older the relay never announces them, by design, so the +rendition stays filtered there, while subscribing to the explicit +`.pro//...` path works on any version. The token's scope must still +reach those routes. So the remaining work is narrow: prove that a rendition covered only by a service's prefix claim is listed and demanded, and fix whatever stops it. diff --git a/quest/m1/broadcast-epoch/README.md b/quest/m1/broadcast-epoch/README.md index 5067a78064..4c008c98e3 100644 --- a/quest/m1/broadcast-epoch/README.md +++ b/quest/m1/broadcast-epoch/README.md @@ -41,7 +41,7 @@ Decided: bare-name viewer behind one needs a publisher that opts out with the raw prefix route. Document this rather than promise it works. - Derived output mirrors the epoch it came from - (`.transcode/pid/foo.hang/@e`, per the + (`.pro/transcode/pid/foo.hang/@e`, per the [wildcard](/quest/m0/wildcard/README.md) line's derived-output layout), so the service's prefix claim still covers it. From b7afe2443c936efa0312d5481e38bb74007e34fc Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 13:20:38 -0700 Subject: [PATCH 6/9] docs(quest): claims authorize by overlap and keep the cost pair Align the wildcard decisions with shipped behavior: a claim must overlap the grant and only routes requests the grant covers, and prefix claims carry the normal warm and cold costs, as the line branch already says. Use the placeholder in the epoch example. Co-Authored-By: Claude Opus 5.5 --- quest/m0/wildcard/README.md | 16 ++++++---------- quest/m1/broadcast-epoch/README.md | 2 +- 2 files changed, 7 insertions(+), 11 deletions(-) diff --git a/quest/m0/wildcard/README.md b/quest/m0/wildcard/README.md index 9b7b1e8569..184ef5c878 100644 --- a/quest/m0/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -50,7 +50,7 @@ would have to start working (a cold transcoder)" (`drafts/draft-lcurley-moq-lite.md`). `moq_auth::Claims.publish` and `origin::Producer` gain versioned patterns through [Path patterns](/quest/m1/path-patterns.md), so advertisements reuse the -same exact containment check. `Cost { warm, cold }` +same matcher. `Cost { warm, cold }` (`rs/moq-net/src/model/origin.rs:426`) is the route cost since [#2925](https://github.com/moq-dev/moq/pull/2925). @@ -130,19 +130,15 @@ field. carries today, and it is the same stride discipline [pop-skipping](/quest/m1/pop-skipping/README.md) states for provider economics. -- **One cost varint, not the pair.** `Cost` is `{ warm, cold }` because a relay - that is carrying a broadcast discounts the warm half. A wildcard carries - nothing and can never be warm, so the two halves are provably equal and the - message carries one value. `From` already means exactly this. - **A wildcard is a capability, not an inventory.** It advertises what the sender could serve, never that a given path exists. Refusal is how a specific path is denied. This is why an over-claiming advertisement is not a defect: the catch-all `**` is legal, and answering "not that one" is the mechanism. -- **Containment against the publish scope is what authorization checks.** An - advertised prefix MUST be contained by the sender's granted patterns (the - matcher's containment check), and any attempted widening is refused rather - than clamped. Fleet-wide services use the cluster identity; a customer service may - advertise only the exact set its own v1 grant contains. +- **Overlap with the publish scope is what authorization checks.** An + advertised prefix MUST overlap the sender's granted patterns or it is + refused. A prefix wider than the grant is accepted, but it only routes + requests for paths the grant covers. Fleet-wide services use the cluster + identity; a customer service serves only what its own v1 grant contains. - **Wildcards are visible to subscribers.** A subscriber sees every pattern matching under its scope, rebased by the matcher's exact set-valued operation, and duplicates combine into one. That is the point: it tells a client it may subscribe to diff --git a/quest/m1/broadcast-epoch/README.md b/quest/m1/broadcast-epoch/README.md index 4c008c98e3..f7486ecd35 100644 --- a/quest/m1/broadcast-epoch/README.md +++ b/quest/m1/broadcast-epoch/README.md @@ -41,7 +41,7 @@ Decided: bare-name viewer behind one needs a publisher that opts out with the raw prefix route. Document this rather than promise it works. - Derived output mirrors the epoch it came from - (`.pro/transcode/pid/foo.hang/@e`, per the + (`.pro/transcode//foo.hang/@e`, per the [wildcard](/quest/m0/wildcard/README.md) line's derived-output layout), so the service's prefix claim still covers it. From 3d63b6499a8429a11fe6ceacf79824e1f91ce607 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 15:18:14 -0700 Subject: [PATCH 7/9] docs(quest): prefix claims in subscriber visibility, advertise scope, cluster-routing deps Co-Authored-By: Claude Opus 5.5 --- quest/m0/wildcard/README.md | 13 ++++++++----- quest/m1/cluster-routing.md | 4 +--- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/quest/m0/wildcard/README.md b/quest/m0/wildcard/README.md index 0e57fcc5cd..09aa0d3327 100644 --- a/quest/m0/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -52,8 +52,8 @@ production cost: zero for a live publish, something large for a standby that would have to start working (a cold transcoder)" (`drafts/draft-lcurley-moq-lite.md`). `moq_auth::Claims.publish` and `origin::Producer` gain versioned patterns through -[Path patterns](/quest/m1/path-patterns.md), so advertisements reuse the -same matcher. `Cost { warm, cold }` +[Path patterns](/quest/m1/path-patterns.md), so tokens and filters reuse the +same matcher; advertisements stay prefixes. `Cost { warm, cold }` (`rs/moq-net/src/model/origin.rs:426`) is the route cost since [#2925](https://github.com/moq-dev/moq/pull/2925). @@ -140,9 +140,12 @@ field. refused. A prefix wider than the grant is accepted, but it only routes requests for paths the grant covers. Fleet-wide services use the cluster identity; a customer service serves only what its own v1 grant contains. -- **Wildcards are visible to subscribers.** A subscriber sees every pattern - matching under its scope, rebased by the matcher's exact set-valued operation, - and duplicates combine into one. That is the point: it tells a client it may subscribe to + Until [Advertise-only authorization](/quest/m1/processor/advertise-auth.md) + lands, the publish scope stands in for advertising; a credential with its own + advertise scope is checked against that instead. +- **Claims are visible to subscribers.** A subscriber sees every advertised + prefix under its scope, filtered locally like any other announcement. That + is the point: it tells a client it may subscribe to matching paths, and its withdrawal tells the client the capability is gone. This is what makes a lazily-produced rendition discoverable without the composer waiting for an announcement that only demand would produce. The diff --git a/quest/m1/cluster-routing.md b/quest/m1/cluster-routing.md index b894d683f6..c647e63662 100644 --- a/quest/m1/cluster-routing.md +++ b/quest/m1/cluster-routing.md @@ -114,13 +114,11 @@ make MoQ's common case. ## Required - [Local origin](/quest/m0/local-origin.md) - workers stop reading hop chains before they go -- [Wildcard](/quest/m0/wildcard/README.md) - the specificity, pool spread, and reply identity this selection builds on +- [Wildcard](/quest/m0/wildcard/README.md) - the longest-prefix rule, pool spread, and reply identity this selection builds on - moq.pro's routing simulator reports ([quest](https://github.com/moq-dev/moq.pro/blob/main/quest/m1/routing-simulator.md)) ## Related - [Skip unchanged announce updates](/quest/m0/announce-update-dedupe.md) - cuts duplicate updates on today's routing -- [Local origin](/quest/m0/local-origin.md) - workers stop reading hop chains before they go -- [Wildcard](/quest/m0/wildcard/README.md) - the longest-prefix rule, pool spread, and reply identity this selection builds on - [Redundant ingest](/quest/m2/redundant-ingest.md) - builds on the `--hop` failover this must keep or replace - [Routing cost domains](/quest/m2/routing-cost-domains.md) - cost across the cluster boundaries this keeps path vector From a99108bf6a41ec977baa6589f6be5b0269782f73 Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 15:22:07 -0700 Subject: [PATCH 8/9] docs(quest): only AUTH grants move to patterns, ANNOUNCE_REQUEST stays a prefix Co-Authored-By: Claude Opus 5.5 --- quest/m1/auth/README.md | 2 +- quest/m1/auth/lite.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/quest/m1/auth/README.md b/quest/m1/auth/README.md index b95317d571..ab481d4558 100644 --- a/quest/m1/auth/README.md +++ b/quest/m1/auth/README.md @@ -120,7 +120,7 @@ existing lite-06 ALPN. ## Related -- [Pattern interest](/quest/m1/path-patterns.md) - moves AUTH's legacy wire prefixes to patterns along with ANNOUNCE_REQUEST +- [Pattern interest](/quest/m1/path-patterns.md) - moves AUTH's legacy grant prefixes to patterns; ANNOUNCE_REQUEST stays a prefix - [Expiring media grants](/quest/m1/processor/grant-lease.md) - a worker's lease renewal is a new in-band token - [P2P](/quest/m1/p2p/README.md) - the first consumer of hop-bound peer grants diff --git a/quest/m1/auth/lite.md b/quest/m1/auth/lite.md index 08b65bd7f6..6d54e56217 100644 --- a/quest/m1/auth/lite.md +++ b/quest/m1/auth/lite.md @@ -147,5 +147,5 @@ On main, additive. ## Related -- [Pattern interest](/quest/m1/path-patterns.md) - moves the prefix - fields here and in ANNOUNCE_REQUEST to patterns together +- [Pattern interest](/quest/m1/path-patterns.md) - moves AUTH's grant + prefixes to patterns; ANNOUNCE_REQUEST stays a prefix From e19d556982e5989ecc82d770c6d867b32615921d Mon Sep 17 00:00:00 2001 From: Luke Curley Date: Mon, 28 Sep 2026 18:22:13 -0700 Subject: [PATCH 9/9] docs(quest): link wildcard to the m3 suffix-announce quest Co-Authored-By: Claude Opus 5.5 --- quest/m0/wildcard/README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/quest/m0/wildcard/README.md b/quest/m0/wildcard/README.md index 09aa0d3327..8e9173b6d0 100644 --- a/quest/m0/wildcard/README.md +++ b/quest/m0/wildcard/README.md @@ -227,3 +227,5 @@ distinguish recording generations reads the catalog's archive entry with an HRW tie-break, built on this line's longest-prefix rule - [Broadcast epochs](/quest/m1/broadcast-epoch/README.md) - derived output mirrors the source path, `@` segment included +- [Suffix announce](/quest/m3/suffix-announce.md) - moq-lite-only suffix + claims, deferred until a service prefix cannot express one