diff --git a/docs/rfcs/product-manifest.md b/docs/rfcs/product-manifest.md index 8e227966..9e7941ae 100644 --- a/docs/rfcs/product-manifest.md +++ b/docs/rfcs/product-manifest.md @@ -16,7 +16,7 @@ A product's bytes live on the Bulletin chain; the manifest describes them. It is The format is two-level: - The **root manifest** carries product-wide metadata (displayName, icon, description) and the product's standing trust grants to other products, and lives at the product's dotNS base name. Authorship is read from dotNS itself (the on-chain owner of the name) rather than declared in the manifest. -- One or more **executable manifests** describe individual executables (App, Widget, Worker). Each pins a product-defined version and lives at a well-known subname of the base name (`app..`, `widget..`, `worker..`). +- One or more **executable manifests** describe individual executables (App, Widget, Funding, Worker). Each pins a product-defined version and lives at a well-known subname of the base name (`app..`, `widget..`, `funding..`, `worker..`). Manifests are JSON, stored inline in dotNS text records. Binary content lives on the Bulletin chain. @@ -33,14 +33,16 @@ A product can expose one or more **modalities**, each a distinct user-facing sur - **Pocket** — passive surfaces such as cards, tickets, or certificates, served by a background JS worker. - **Chat** — chat bots and chat-room integrations, served by a background JS worker. - **Input** — an input surface for contextual interactions with the Products, served by a background JS worker. +- **Funding** — a surface for moving value between the user's balance and an external rail, rendered from a web application the product publishes. -A modality is delivered by an **executable**. v1 defines three executable types: +A modality is delivered by an **executable**. v1 defines four executable types: - **App** — the web application backing the App modality. - **Widget** — the web application backing the Widget modality. +- **Funding** — the web application backing the Funding modality. - **Worker** — a single background process. It may back any combination of Pocket, Chat, and Input, or serve no user-facing surface at all and run purely as background logic (see [Why one Worker, not per modality](#executable-manifest-v1)). -Throughout this RFC, *modality* means a user-facing surface; *executable* means a deployable artifact. +Throughout this RFC, _modality_ means a user-facing surface; _executable_ means a deployable artifact. Two on-chain systems sit under this RFC: @@ -71,6 +73,7 @@ A product is rooted at a **dotNS base name** (e.g. `game.dot`). The base name's game.dot → root manifest (displayName, icon, description) app.game.dot → executable manifest (App) widget.game.dot → executable manifest (Widget) +funding.game.dot → executable manifest (Funding) worker.game.dot → executable manifest (Worker; serves Pocket, Chat and/or Input) ``` @@ -83,14 +86,14 @@ Manifests are encoded as **UTF-8 JSON** and stored **inline** in a single, well- The records are fixed by this RFC: | Subject | Record | -|------------------------------------|--------------------------| +| ---------------------------------- | ------------------------ | | Root manifest (on the base name) | text record `manifest` | | Executable manifest (on a subname) | text record `executable` | | Executable bytes (on a subname) | `contenthash` | Hosts MUST read exactly these records; publishers MUST write exactly these records. Keeping them distinct means a wrong-layer query (e.g. `manifest` on `app..`) returns an empty value instead of partially parsing a payload of the wrong shape. -The split between the last two rows is deliberate: the manifest *describes* an executable, the `contenthash` *locates* it. No CID for executable bytes appears inside the JSON. Hosts MUST read an IPFS-codec `contenthash`; other codecs are not part of v1. +The split between the last two rows is deliberate: the manifest _describes_ an executable, the `contenthash` _locates_ it. No CID for executable bytes appears inside the JSON. Hosts MUST read an IPFS-codec `contenthash`; other codecs are not part of v1. A v1 root manifest carrying no trust grants is well under 1 KB; an executable manifest is ~200 B. `trustedProducts` is the only unbounded field in v1 and counts against the same budget. Manifests fit within typical text-record budgets; the exact figure will be confirmed by the dotNS team's Proof-of-Concept (see [Unresolved Questions](#unresolved-questions)). v1 defines no preimage fallback: a manifest that cannot fit MUST be shrunk by the publisher. @@ -105,18 +108,18 @@ The root manifest describes the product as a whole and is the resolution entry p ```typescript type RootManifest = { $v: 1; - displayName: string; // Human-readable product name. UTF-8. - description: string; // Short description shown in launchers/lists. - icon: Icon; // Product icon used by every Host surface. + displayName: string; // Human-readable product name. UTF-8. + description: string; // Short description shown in launchers/lists. + icon: Icon; // Product icon used by every Host surface. trustedProducts?: Record; // ``, no TLD suffix → what that product may do to this one. }; type Icon = { - cid: string; // Bulletin-chain CID; used verbatim to fetch icon bytes. - format: 'jpeg' | 'png'; // Formats defined by v1. An unrecognised value is tolerated, not fatal. + cid: string; // Bulletin-chain CID; used verbatim to fetch icon bytes. + format: "jpeg" | "png"; // Formats defined by v1. An unrecognised value is tolerated, not fatal. }; -type Granted = 'all'; // The only grant v1 defines. Unrecognised values are ignored, not fatal. +type Granted = "all"; // The only grant v1 defines. Unrecognised values are ignored, not fatal. ``` #### Icons @@ -131,10 +134,10 @@ Two obligations follow. A validator MUST accept a `RootManifest` whose `format` Products interact through the Host — reading another product's account, asking it to sign. Each such interaction is normally a consent decision; `trustedProducts` pre-approves the ones the publisher has already judged safe, and the Host does not prompt for them. -**The grant is issued by the product being accessed.** An entry in A's manifest states what B may do *to A* — the only direction A's name can authenticate. It says nothing about what A may do to B, nor about the products B in turn trusts. +**The grant is issued by the product being accessed.** An entry in A's manifest states what B may do _to A_ — the only direction A's name can authenticate. It says nothing about what A may do to B, nor about the products B in turn trusts. - **Keys** are bare `` labels, lowercase, with no TLD suffix: `"wallet"`, never `"wallet.dot"`. The Host appends the TLD of the network it resolves against. A key that does not resolve there is inert, not a validation error. -- **Values** are that product's grants. v1 defines one, `all` — a wildcard for the complete set of cross-product permissions the Host mediates on this product's behalf. It is resolved against that set when the grant is used, not enumerated here, so a grant of `all` covers permissions added after it was published. Hosts MUST ignore unrecognised values, keep the recognised ones, and MUST NOT fail validation over them. +- **Values** are that product's grants. v1 defines one, `all` — a wildcard for the complete set of cross-product permissions the Host mediates on this product's behalf. It is resolved against that set when the grant is used, not enumerated here, so a grant of `all` covers permissions added after it was published. Hosts tolerate unrecognised values (see [Corner cases](#corner-cases)). - **Absence means no grants.** Missing field, empty record, and empty array are equivalent: prompt as usual. A product listing itself is ignored. Which interactions a Host mediates, and what the prompt looks like, are Host runtime contracts; this RFC defines only how the grants are published and read. @@ -145,32 +148,38 @@ An executable manifest describes one deployable artifact and lives on a well-kno ```typescript type ExecutableManifest = - | AppManifest - | WidgetManifest - | WorkerManifest; + AppManifest | WidgetManifest | FundingManifest | WorkerManifest; type CommonExecutableFields = { $v: 1; - appVersion: SemVer; // Product-defined SemVer of this executable. + appVersion: SemVer; // Product-defined SemVer of this executable. }; type AppManifest = CommonExecutableFields & { - kind: 'app'; + kind: "app"; }; type WidgetManifest = CommonExecutableFields & { - kind: 'widget'; - description?: string; // Optional tagline shown on the widget card. + kind: "widget"; + description?: string; // Optional tagline shown on the widget card. dimensions: { - height: number[]; // Supported grid-step heights the widget can render at. - width?: number; // Grid-step width. Optional; defaults to 1 column. + height: number[]; // Supported grid-step heights the widget can render at. + width?: number; // Grid-step width. Optional; defaults to 1 column. }; }; +type FundingManifest = CommonExecutableFields & { + kind: "funding"; + modes: FundingMode[]; // Rails the surface supports. Non-empty. +}; + +type FundingMode = "CARD" | "BANK" | "CRYPTO"; + type WorkerManifest = CommonExecutableFields & { - kind: 'worker'; - entrypoint: string; // Path to the worker entry module inside the executable directory. - includes: { // Surfaces served; an omitted key means `false`. + kind: "worker"; + entrypoint: string; // Path to the worker entry module inside the executable directory. + includes: { + // Surfaces served; an omitted key means `false`. pocket?: boolean; chat?: boolean; input?: boolean; @@ -183,11 +192,12 @@ type SemVer = [major: number, minor: number, patch: number, build?: string]; - `app` — full-screen App. No extra fields beyond the common ones. - `widget` — `dimensions.height` is the list of grid-step heights the widget can render at; the Host picks one per layout. `width` defaults to `1` column. The grid unit and bounds belong to the Host's dashboard spec (see [Future Directions](#future-directions)). By convention `8` in `height` signals a full-screen widget; this RFC does not normalise that convention. +- `funding` — the web application the Host renders when the user selects this product as a funding source. `modes` lists the rails the surface supports; the Host matches them against the funding flow at hand, tolerating unrecognised values per [Corner cases](#corner-cases). The runtime contract behind the surface is deferred to a dedicated Funding Modality RFC. - `worker` — background JS worker. `entrypoint` is the module the Host loads inside the worker. `includes` declares which surfaces it serves. **`appVersion` is a label, not a change signal.** Hosts detect a new deployment from the subname's `contenthash`, not from this field (see [Cache invalidation](#resolving-a-product)). `appVersion` names the release for the user — "update to 1.4.0", "you declined 1.3.2" — so publishers SHOULD keep it meaningful, but nothing about resolution or caching depends on it moving. -Publishers MUST set `kind` to match the subname label the manifest is written under: `app` under `app..`, `widget` under `widget..`, `worker` under `worker..`. Hosts MUST reject a manifest whose `kind` does not match the subname it was read from. +Publishers MUST set `kind` to match the subname label the manifest is written under: `app` under `app..`, `widget` under `widget..`, `funding` under `funding..`, `worker` under `worker..`. Hosts MUST reject a manifest whose `kind` does not match the subname it was read from. **Why one Worker, not per modality.** A Worker is the product's single background process, carrying its full Host-API surface (signing, notifications, chain access, long-lived caches). Those capabilities do not split cleanly along the boundaries between Pocket, Chat, and Input, and one bundle per surface would duplicate that surface area and make the product's on-chain signing identity ambiguous. `includes` only advertises which user-facing affordances the same process serves; the executable remains a single artifact. @@ -195,26 +205,19 @@ Publishers MUST set `kind` to match the subname label the manifest is written un The subname's `contenthash` record points at the bytes; this section defines what those bytes contain. Runtime APIs a Host exposes to a running executable (chain access, message passing, lifecycle hooks, etc.) are out of scope here — those belong in per-modality runtime contracts. -**App and Widget.** Single-page web applications, packaged as a directory whose root contains an `index.html` file. The Host treats `index.html` as the entry point and loads it to launch the modality. Relative paths inside `index.html` (scripts, styles, images) resolve against the same Bulletin IPFS gateway root from which the executable was fetched. +**App, Widget, and Funding.** Single-page web applications, packaged as a directory whose root contains an `index.html` file. The Host treats `index.html` as the entry point and loads it to launch the modality. Relative paths inside `index.html` (scripts, styles, images) resolve against the same Bulletin IPFS gateway root from which the executable was fetched. **Worker.** A directory of JavaScript files. The executable manifest's `entrypoint` field names the entry-point module as a path relative to the directory root (e.g. `index.js`, `src/worker.js`). The Host loads that module into a JS worker runtime to launch the modality. Other files referenced from the entry module (static imports, dynamic-import paths, asset URLs) resolve against the same Bulletin IPFS gateway root. ### Subname convention -| Subname | Carries | -|-----------------------------|----------------------------| -| `app..` | App executable manifest | -| `widget..` | Widget executable manifest | -| `worker..` | Worker executable manifest | - -A product MAY publish any combination of these subnames; absence of a subname means the product does not provide that executable. - -For each executable type the Host can render, it MUST query the corresponding subname to discover whether the product provides that executable. A Host with no surface for an executable type (e.g. a CLI Host has no dashboard for widgets) MAY skip the corresponding subname. +A product MAY publish any combination of the executable subnames listed in [Overview](#overview). For each executable type the Host can render, it MUST query the corresponding subname to discover whether the product provides that executable. A Host with no surface for an executable type (e.g. a CLI Host has no dashboard for widgets) MAY skip the corresponding subname. ### Corner cases - **Any icon failure** — `cid` unreachable, `format` unrecognised, or bytes that do not decode as the declared `format`. Render a placeholder; do not sniff or auto-correct. The root manifest stays valid and the product remains launchable. - **Unrecognised grant value in `trustedProducts`.** Ignore that value and keep the recognised ones; the root manifest stays valid. Same for a key naming a product that does not resolve — the entry is inert. +- **Unrecognised mode value in the Funding manifest `modes`.** Ignore that value and keep the recognised ones; the manifest stays valid. If no recognised mode remains, the product is not offered for funding — this is not an error. - **Executable `contenthash` unset, non-IPFS codec, or undecodable.** That executable cannot be launched; surface a diagnostic. The product stays discoverable and its other executables still launch. - **Missing root manifest but present executable subnames.** Product is not discoverable; executables MUST NOT be launched. - **Unknown `kind` in an executable manifest.** Skip that executable rather than fail the whole product. @@ -240,7 +243,7 @@ Everything in this list is a **parameter** the implementation accepts as input; Fixed by the Bulletin chain protocol; identical across every publisher and Host. | Constant | Value | Meaning | -|---------------------------|--------------------------|---------------------------------------------| +| ------------------------- | ------------------------ | ------------------------------------------- | | CID version | `1` | CIDv1 | | Multihash code | `0xb220` (`blake2b-256`) | Hash algorithm used to derive the CID | | Digest length | `32` bytes | Output size of the BLAKE2b-256 digest | @@ -292,45 +295,21 @@ type LocalProductConfig = { trustedProducts?: Record; // Same shape as RootManifest.trustedProducts; keys are bare product ids. app?: AppConfig; widget?: WidgetConfig; + funding?: FundingConfig; worker?: WorkerConfig; }; - -type AppConfig = { - root: string; // Path to the executable directory on disk. - appVersion: SemVer; // Same SemVer tuple as the matching ExecutableManifest. -}; - -type WidgetConfig = { - root: string; // Path to the executable directory on disk. - appVersion: SemVer; // Same SemVer tuple as the matching ExecutableManifest. - description?: string; // Optional tagline shown on the widget card. - dimensions: { - height: number[]; // Supported grid-step heights the widget can render at. - width?: number; // Grid-step width. Optional; defaults to 1 column. - }; -}; - -type WorkerConfig = { - root: string; // Path to the executable directory on disk. - appVersion: SemVer; // Same SemVer tuple as the matching ExecutableManifest. - entrypoint: string; // Path to the worker entry module inside the executable directory. - includes: { // Same shape as WorkerManifest.includes; all may be false for a background-only worker. - chat?: boolean; - pocket?: boolean; - input?: boolean; - }; -}; ``` -Each executable field (`app`, `widget`, `worker`) is optional — omitting it means that executable is not part of this publish operation. +Each executable config mirrors the matching `ExecutableManifest` variant — the same fields minus `$v`, plus `root`, the path to the executable directory on disk. Each executable field is optional; omitting it means that executable is not part of this publish operation. #### Step 2 — Validate the local config -The publisher validates the local config before any network I/O: +The publisher validates the local config before any network I/O. Validation is strict where Hosts are tolerant: Hosts ignore unrecognised icon formats, grants, and funding modes; publishers MUST NOT emit them. - All referenced files (icon, executables) exist and are readable. -- Icon `format` is one of the values allowed by `Icon.format`. This stays strict on the publishing side: Hosts tolerate an unrecognised `format`, publishers MUST NOT emit one. -- Every `trustedProducts` key is a bare `` label carrying no TLD suffix, and every grant value is one defined by `Granted`. Strict on the publishing side, as with the icon format: Hosts ignore unrecognised grants, publishers MUST NOT emit them. +- Icon `format` is one of the values allowed by `Icon.format`. +- Every `trustedProducts` key is a bare `` label carrying no TLD suffix, and every grant value is one defined by `Granted`. +- `funding.modes` is non-empty and every value is one defined by `FundingMode`. - `appVersion` is a 3- or 4-element tuple of the right shape. - Each executable's kind-specific fields are present, well-typed, and satisfy schema-level constraints. - Pessimistic size preflight: compose the root manifest with a placeholder icon CID of the fixed encoded length (per the Constants table), and compose each executable manifest exactly — they carry no CID, so their size is already final. Abort if any composed manifest exceeds the dotNS text-record budget. @@ -353,7 +332,7 @@ Read `IDotnsRegistry.resolver(namehash("."))`; if it isn't the ##### 3.3 Subnames for each executable -For each executable being published, ensure the corresponding subname (`app..`, `widget..`, or `worker..`) exists with the publisher as owner. If not, call `IDotnsRegistry.setSubnodeOwner({ parentNode, subLabel, parentLabel, owner })` with the publisher's address as owner. +For each executable being published, ensure the corresponding subname exists with the publisher as owner. If not, call `IDotnsRegistry.setSubnodeOwner({ parentNode, subLabel, parentLabel, owner })` with the publisher's address as owner. `setSubnodeOwner` installs the reverse resolver on fresh subnodes (same default as base names), so each subnode also needs its resolver redirected: call `IDotnsRegistry.setSubnodeResolver({ parentNode, subLabel, parentLabel, resolver: })` (or `setResolver(subnode, …)` once the subnode is owned by the publisher). The subnode's resolver holds both the `executable` text record and the `contenthash` written in Step 7. @@ -364,7 +343,9 @@ For each executable being published, ensure the corresponding subname (`app.)`: ```typescript -const auth = await api.query.TransactionStorage.Authorizations.getValue(Enum("Account", signerAddress)); +const auth = await api.query.TransactionStorage.Authorizations.getValue( + Enum("Account", signerAddress), +); ``` The returned record has shape `{ extent: { transactions_allowance, transactions, bytes_allowance, bytes }, expiration }`, or `undefined` if the account has never been authorized. The signing key is usable iff: @@ -386,7 +367,7 @@ TransactionStorage.store_with_cid_config({ cid: { codec, hashing }, data }) Larger artifacts are merkleized first: bytes are chunked and arranged into a Merkle DAG with a single root CID (serialised as a CAR — Content-Addressed aRchive). Two kinds of artifacts go through the same flow: 1. **The product icon.** Read the icon file from disk, merkleize (typically a single chunk for small images), and upload each chunk via `store_with_cid_config`. The resulting root CID becomes the root manifest's `icon.cid`. -2. **Each executable.** For each executable type (App, Widget, Worker) being published, read the executable directory, merkleize into a CAR, and upload each chunk via `store_with_cid_config`. The chain does not deduplicate server-side, so the publisher MUST probe each chunk's CID against the chain or its IPFS gateway and skip any that are already present. The resulting root CID is written to that executable's subname `contenthash` in Step 7. +2. **Each executable.** For each executable type (App, Widget, Funding, Worker) being published, read the executable directory, merkleize into a CAR, and upload each chunk via `store_with_cid_config`. The chain does not deduplicate server-side, so the publisher MUST probe each chunk's CID against the chain or its IPFS gateway and skip any that are already present. The resulting root CID is written to that executable's subname `contenthash` in Step 7. Assets that fail to upload abort the publish. Re-running the publish is safe: chunks already on-chain are re-addressable by their CID and skipped on retry. @@ -395,7 +376,7 @@ Assets that fail to upload abort the publish. Re-running the publish is safe: ch With every CID in hand, the publisher constructs: - One **root manifest** JSON conforming to `RootManifest`, with the icon's `cid` and `format` substituted in. -- One **executable manifest** JSON per executable conforming to the matching `AppManifest` / `WidgetManifest` / `WorkerManifest` shape. Executable manifests reference no CID; the artifact is bound to the subname's `contenthash` in Step 7. +- One **executable manifest** JSON per executable conforming to the matching `ExecutableManifest` variant. Executable manifests reference no CID; the artifact is bound to the subname's `contenthash` in Step 7. All payloads start with `$v: 1`. @@ -404,7 +385,7 @@ All payloads start with `$v: 1`. Before any dotNS write, the publisher: 1. Parses each composed JSON back through the v1 JSON Schema to confirm conformance. -2. Computes the UTF-8 byte length of each manifest and rejects any that exceed the dotNS text-record budget (the exact figure is still TBD — see [Unresolved Questions](#unresolved-questions)). +2. Computes the UTF-8 byte length of each manifest and rejects any that exceed the dotNS text-record budget. Either check failing aborts the publish before on-chain writes begin (see [Security § Size cap at publishing](#security)). @@ -414,17 +395,19 @@ Either check failing aborts the publish before on-chain writes begin (see [Secur To enable rollback, the publisher first **snapshots** every record it will touch by reading the current value via `IDotnsContentResolver.text(node, key)` / `contenthash(node)`: -| Node | Record | Snapshot of | -|-----------------------------------------|-----------------------|-------------------------------------| -| `namehash(".")` | text `manifest` | Existing root manifest (if any) | -| `namehash("app..")` | text `executable` | Existing App executable manifest | -| `namehash("app..")` | `contenthash` | Existing App executable CID | -| `namehash("widget..")` | text `executable` | Existing Widget executable manifest | -| `namehash("widget..")` | `contenthash` | Existing Widget executable CID | -| `namehash("worker..")` | text `executable` | Existing Worker executable manifest | -| `namehash("worker..")` | `contenthash` | Existing Worker executable CID | +| Node | Record | Snapshot of | +| ---------------------------------------- | ----------------- | ------------------------------------ | +| `namehash(".")` | text `manifest` | Existing root manifest (if any) | +| `namehash("app..")` | text `executable` | Existing App executable manifest | +| `namehash("app..")` | `contenthash` | Existing App executable CID | +| `namehash("widget..")` | text `executable` | Existing Widget executable manifest | +| `namehash("widget..")` | `contenthash` | Existing Widget executable CID | +| `namehash("funding..")` | text `executable` | Existing Funding executable manifest | +| `namehash("funding..")` | `contenthash` | Existing Funding executable CID | +| `namehash("worker..")` | text `executable` | Existing Worker executable manifest | +| `namehash("worker..")` | `contenthash` | Existing Worker executable CID | -The table shows a publish covering all three executables; an executable absent from this publish has its rows left untouched. Empty values indicate first-time publish for that record. +The table shows a publish covering every executable type; an executable absent from this publish has its rows left untouched. Empty values indicate first-time publish for that record. The publisher then submits one write per row: `setText(...)` carrying the newly composed JSON, and `setContenthash(...)` binding each executable's uploaded root CID to its subname. The writes are independent and SHOULD be batched into a single signed extrinsic via `Utility.batchAll`, so every record lands in a single block or the entire batch fails atomically — a manifest visible without its `contenthash` describes an executable a Host cannot fetch. @@ -439,7 +422,7 @@ After all writes confirm, the publisher re-runs the resolution flow described in - Every executable subname's `contenthash` decodes to the root CID just uploaded for that executable. - The root manifest's `icon.cid` and every executable CID are reachable on the Bulletin chain. Two equivalent probes: an HTTP `HEAD /ipfs/` returning 2xx, or an on-chain read of `TransactionStorage.TransactionByContentHash(Blake2_128_Concat(content_hash))` returning a non-empty entry. The HTTP probe is faster; the on-chain probe is authoritative. -If any assertion fails, treat as a write failure: trigger the snapshot-restore path from Step 7's *Rollback on partial failure*, then abort with a diagnostic. +If any assertion fails, treat as a write failure: trigger the snapshot-restore path from Step 7's _Rollback on partial failure_, then abort with a diagnostic. ### Host implementation @@ -453,17 +436,20 @@ For a base name `B`: 2. **Find the resolver.** Read `IDotnsRegistry.resolver(node)`. Returns the address of the resolver holding records for the node. `address(0)` → product does not exist; surface a diagnostic. 3. **Read the root manifest.** Read `IDotnsContentResolver.text(node, "manifest")` on the resolver from step 2. Returns the inline manifest JSON string. An empty string indicates the product does not exist; this also covers the case where the resolver is still the dotNS-issued default (the reverse resolver does not implement `text`). 4. **Parse and validate the root manifest.** - - Parse the string as JSON. Failure → malformed; surface a diagnostic. - - Validate `$v`. Unknown version → undiscoverable; surface a diagnostic and keep working for other products. - - Validate against the v1 `RootManifest` schema. Validation failure → malformed; do not partially trust the result. - - Two things are exempt and MUST NOT fail validation: an unrecognised `icon.format`, and an unrecognised grant value in `trustedProducts` (see [Root manifest (v1)](#root-manifest-v1)). + +- Parse the string as JSON. Failure → malformed; surface a diagnostic. +- Validate `$v`. Unknown version → undiscoverable; surface a diagnostic and keep working for other products. +- Validate against the v1 `RootManifest` schema. Validation failure → malformed; do not partially trust the result. +- Two things are exempt and MUST NOT fail validation: an unrecognised `icon.format`, and an unrecognised grant value in `trustedProducts` (see [Root manifest (v1)](#root-manifest-v1)). + 5. **(Optional) Read the author.** Hosts that surface authorship in UI call `IDotnsRegistry.owner(node)` — a single call that returns the canonical owner. The registry transparently handles the ERC-721 fallback for second-level names, so callers do not need to distinguish that case from subnodes. -6. **Probe executable subnames.** For each of `app..`, `widget..`, `worker..` whose executable type the Host can render (per [Subname convention](#subname-convention)): compute the subnode's namehash and repeat steps 2-4 against it, using `text(subnode, "executable")` instead of `text(node, "manifest")` and parsing against the matching `ExecutableManifest` variant. A subnode that does not exist (resolver `address(0)`) or has an empty text record means the product does not provide that executable — this is not an error. -7. **(Optional) Verify subname provenance.** By convention executable subnames are owned by the same account as the base name; the registry does not enforce that. A Host that needs strict provenance MUST call `owner(subnode)` for each present subname and verify equality with the base-name owner from step 5. +6. **Probe executable subnames.** For each executable subname whose type the Host can render (per [Subname convention](#subname-convention)): compute the subnode's namehash and repeat steps 2-4 against it, using `text(subnode, "executable")` instead of `text(node, "manifest")` and parsing against the matching `ExecutableManifest` variant. A subnode that does not exist (resolver `address(0)`) or has an empty text record means the product does not provide that executable — this is not an error. +7. **(Optional) Verify subname provenance.** By convention executable subnames are owned by the same account as the base name; the registry does not enforce that. A Host that needs strict provenance MUST call `owner(subnode)` for each present subname, verify equality with the base-name owner from step 5, and skip executables that fail the check. 8. **Fetch executable bytes before launching.** For each executable manifest the Host intends to launch: - - Read `contenthash(subnode)` on that subnode's resolver from step 6 and decode it to a CID. An unset slot, a non-IPFS codec, or bytes that fail to decode → cannot launch that executable; surface a diagnostic. - - `GET /ipfs/` from the Bulletin IPFS gateway. A CID that cannot be fetched → cannot launch that executable; surface a diagnostic. - - v1 specifies no byte-level verification step (see [Unresolved Questions](#unresolved-questions)). + +- Read `contenthash(subnode)` on that subnode's resolver from step 6 and decode it to a CID. An unset slot, a non-IPFS codec, or bytes that fail to decode → cannot launch that executable; surface a diagnostic. +- `GET /ipfs/` from the Bulletin IPFS gateway. A CID that cannot be fetched → cannot launch that executable; surface a diagnostic. +- v1 specifies no byte-level verification step (see [Unresolved Questions](#unresolved-questions)). For the optional icon-byte fetch, the same gateway mechanics apply against the root manifest's `icon.cid` — no `contenthash` read, since the icon CID is carried in the manifest itself. A missing or undecodable icon is a UX degradation, not a launch blocker (see [Corner cases](#corner-cases)). @@ -475,32 +461,16 @@ Manifests carry no such signal, so re-read them on whatever schedule suits. One #### Conformance fixtures -A conforming Host implementation should produce well-defined behaviour for each of: +Every corner case above, and every failure named in the resolution flow, is a conformance fixture: a conforming Host produces the stated behaviour for each. Two cases deserve their own entries, since nothing above names them: -- Base name with no resolver (`resolver(node) == address(0)`) → product does not exist. - Base name whose resolver is still the dotNS-default reverse resolver → product does not exist. -- Empty `text(node, "manifest")` → product does not exist. -- Malformed JSON in `manifest` → diagnostic; do not launch. -- Unknown `$v` in `manifest` → diagnostic; treat as undiscoverable. -- Root manifest fails `RootManifest` schema → diagnostic; do not launch. -- Unknown `icon.format` → root manifest still validates; render placeholder; product remains launchable. -- Unknown grant value in `trustedProducts` → root manifest still validates; that value is ignored, recognised values in the same entry still apply. -- `trustedProducts` key naming a product that does not resolve → entry is inert; root manifest still validates. - `trustedProducts` key written with a TLD suffix (`"wallet.dot"` instead of `"wallet"`) → resolves to `wallet.dot.`, does not exist, entry is inert; grants nothing. -- Icon CID unreachable → render placeholder; product remains launchable. -- Icon bytes do not decode as the declared `format` → render placeholder; product remains launchable. -- Executable subname absent or empty `executable` text record → product does not provide that executable. -- Executable manifest fails its `ExecutableManifest` variant schema → skip that executable. -- Unknown `kind`, or `kind` does not match the subname label → skip that executable. -- Executable subname `contenthash` unset, non-IPFS codec, or undecodable → cannot launch that executable; surface a diagnostic. -- Executable CID unreachable → cannot launch that executable; surface a diagnostic. -- Executable subname owned by a different account than the base name (when strict provenance is enabled) → skip that executable. ## Drawbacks - **JSON over a binary codec.** Costs text-record budget that a binary format would not — accepted for parseability with off-the-shelf tooling. - **No oversized-manifest fallback.** A publisher who exceeds the dotNS text-record budget MUST shrink the payload. -- **Multiple lookups per resolution.** A full resolution for a product with all three executable types costs ~11 dotNS reads plus up to 4 Bulletin fetches. Mitigate with parallelisation and caching. +- **Multiple lookups per resolution.** A full resolution for a product with all four executable types costs ~14 dotNS reads plus up to 5 Bulletin fetches. Mitigate with parallelisation and caching. - **Schema evolution locks out older Hosts.** A new `$v` is invisible to Hosts that do not yet recognise it. A co-versioning scheme is left to a follow-up RFC. ## Alternatives @@ -513,7 +483,7 @@ A conforming Host implementation should produce well-defined behaviour for each - **Trust anchor.** The dotNS name is the identity; the root manifest's `icon.cid` and each subname's `contenthash` bind that identity to specific bytes on Bulletin. That binding is only as strong as the fetch path: v1 defines no Host-side check that fetched bytes hash back to the `cid` they were requested by, so a Host trusts its Bulletin IPFS gateway to serve the addressed bytes. Byte-level verification is deferred (see [Unresolved Questions](#unresolved-questions)); until it lands, gateway selection is a security decision. - **Icon supply chain.** Icon bytes are decoded and drawn, never executed, so the containment control is the render surface: Hosts MUST render icon bytes through a sandboxed image surface (e.g. ``) and never through a path that interprets them as markup or script. That MUST holds whatever the declared `format` says, which is why an unrecognised `format` is a rendering decision rather than a validity one — a Host that cannot decode a format renders a placeholder. - **Trust grants are publisher-declared, not user-declared.** A grant is authenticated by nothing stronger than dotNS ownership, so a compromised or transferred name widens access with one `setText`. Two constraints follow. A grant MUST NOT override a denial the user already gave — it waives the publisher's prompt, never the user's. And revocation is a record edit with no signal attached, so a Host that honours a cached grant indefinitely cannot be revoked from (see [Cache invalidation](#resolving-a-product)). -- **Size cap at publishing.** The publisher MUST validate every manifest against the v1 schema and reject payloads exceeding the dotNS text-record budget before submitting. dotNS enforces a wire-level cap on writes. +- **Size cap at publishing.** The publisher MUST reject payloads exceeding the dotNS text-record budget before submitting (see Step 6). dotNS enforces a wire-level cap on writes. - **Subname squatting is structurally prevented.** `setSubnodeOwner` is gated by parent-ownership: only the owner of `.` can create the modality subnames. - **No user data.** The manifest carries no user data; privacy exposure is limited to whatever dotNS RPC traffic reveals about which products a client is resolving. @@ -527,3 +497,4 @@ A conforming Host implementation should produce well-defined behaviour for each - `Granted` will gain per-capability values (account read, signing, …) alongside `all` once the Host runtime contracts name those capabilities; `all` stays the wildcard, and the array shape and the ignore-unrecognised-values rule let the narrower values land without a new `$v`. - A manifest-aggregation RPC could eliminate the N+1 lookup pattern (one round-trip per subname) without changing the schema. - A companion spec will pin down the dashboard grid (cell size, bounds, responsive behaviour) referenced by `WidgetManifest.dimensions`. +- `FundingMode` will grow beyond `CARD`, `BANK`, and `CRYPTO`. A dedicated Funding Modality RFC will define the runtime contract behind the Funding surface: intents, progress reporting, and observation of funds on the network. The array shape and the ignore-unrecognised-values rule let new modes land without a new `$v`.