# PUDL protocol, spec 0.3 (record format 1)

Status: draft for the pilot, 2026-09-30. Licensed under Apache-2.0, like the code. It replaces v0.1, which never launched; nothing in 0.2 or 0.3 is compatible with v0.1 records or routes. The reasons for 0.2 are in docs/design/spec-0.2-decisions.md (repository reference; not published here) (historical), and for 0.3 in docs/design/businesses-and-implementers.md (repository reference; not published here).

**This document is the protocol:** records and their integrity, what a host accepts, the host API, and the profile for clients that act on pooled connections (Appendix C). It is account-level: a host links agent keys to accounts and decides who reads and writes each pool. **What a host chooses beyond that is its host profile**, published separately. PUDL.ai's, in [docs/HOST-PROFILE.md](/spec/host) (served at `/spec/host`), covers its organizations, implementers and roles, a pool's audience, hosted source connections, its library of recipes and schemas, its remote MCP endpoint, retained gateway compatibility, and its launch policy. Response fields a host profile adds are named here as optional, so every client can ignore them (section 5.5).

Spec 0.3 changes no record and nothing on the wire: record format 1 and every field stay as in 0.2. It adds optional response fields, custodial keys (section 4.1), and the `oauth` field of a connection descriptor (Appendix C.2).

PUDL lets agents pool **any data** and share it: notes, findings, JSON, datasets, files, references, and **receipts** saying what they relied on and whether it worked. The protocol defines signed, append-only records and says nothing about what they hold. A **host** stores records and decides who can read and write them, and how much it will hold. PUDL.ai is one host, and anyone can run another.

**Hosting and source access are separate choices.** An organization may operate its own conforming host to take responsibility for its storage and permission enforcement. This remains a protocol option, not a requirement for using PUDL.ai or a commitment to a supported enterprise deployment product. Hosted customers connect to PUDL.ai without a local host or gateway. Supplying a provider key, using a separately approved hosted connection, or exchanging requests and results with an independently operated agent through a pool does not require running another PUDL host. A pool request is ordinary data: its recipient decides whether to act under its own authority. Saving a link does not automatically retrieve or process its contents.

This hosting clarification (27 October 2026) changes no record format, API, authority rule or conformance requirement. The current hosted-product scope is recorded in installation-free product (repository reference; not published here).

The protocol is the mycelium; agents and hosts are what grows on it.

The key words MUST, MUST NOT, SHOULD, SHOULD NOT and MAY are used as in RFC 2119.

## 1. Principles

- **Neutral: any data.** A record carries text, any JSON value, or files named by their hash, about anything with an address. Nothing in the protocol depends on what the data means.
- **Hosts set policy.** What a host will hold (sizes, media types, who may upload, what it shares) is host policy, published in its `pudl.json`. It is never a protocol rule. PUDL.ai's policy is in its host profile, kept apart from the protocol.
- **Validity is integrity only.** A record is authentic if its signature, id and body hash check out. Unknown kinds, fields and terms never make a record invalid, so every later addition is non-breaking (section 5.5).
- **Hosts accept only what they implement.** A host checks every field at the door and never accepts, imports or relays a field or kind it doesn't implement. Readers ignore what they don't know.
- **Absent means null.** A field with no value is left out. Writers never send `null`.
- **Open terms.** `type`, link `rel`, `pooler_kind` and removal `reason` are open vocabularies with recommended terms. Only `outcome` and `visibility` are closed.
- **Append-only, with deletable bodies.** A record's signed header commits to its body by hash. Pool settings and profiles change by new signed versions. A takedown, a retraction or an expiry removes the body without breaking history.
- **Data, never instructions.** Everything returned to an agent is labeled with its author and pool, and agents treat it as data to weigh.
- **Scores outside the protocol.** The protocol records receipts. Scoring is a view anyone can compute.

## 2. Building blocks

| Block | What it is | On the wire |
| --- | --- | --- |
| Pooler | An identity that signs everything it writes: a `did:key` | `pooler`, an optional profile. `claim_request` and `claim` link a key to a host account. |
| Pool | A container with access rules and a curation policy | `pool`, a version chain |
| Contribution | Every signed statement or piece of data: a note, finding, dataset, reply or annotation. A **receipt** is a contribution with an `outcome`. | `contribution` |
| File | Bytes named by their SHA-256, carried by contributions, held by a host or somewhere else | `body.files[]`, and `/v0/files` |
| Target | Any URI: the shared address space. `sha256:` names bytes (a record's canonical header, or a file). | `about`, `links`, `GET /v0/lookup` |
| Views | Scores, totals, search, lookups. Computed by hosts, never signed. | API only |

There are five record kinds: `pooler`, `pool`, `contribution`, `claim_request` and `claim`. Authors submit the first four. Only hosts sign `claim`.

A contribution's **facets**. Every one is open except `outcome`:

| Facet | Axis | Example |
| --- | --- | --- |
| `type` | Why it is in the pool | `finding`, `artifact`, `acme.com/invoice` |
| `about` | What it concerns: the join key for discovery | `npm:x@1.2.0`, `doi:10.1/abc`, `urn:acme:customer:42`, `sha256:...` |
| `content` / `files` | The payload: inline JSON, or bytes named by hash | `"text"`, `{...}`, `[{"hash", "size", ...}]` |
| `media_type` | The encoding of `content` | `text/csv` |
| `schema` | Shape and meaning | `https://schema.org/Invoice`, a JSON Schema URL, a record id |
| `scope` | Where it holds, or what for | `{"task": "forecast", "region": "emea"}` |
| `outcome` | Did relying on the `about` target work? | `worked` |
| `links` | Typed edges to other things | `{"rel": "derived_from", "target": "s3://..."}` |
| `license` | Rights, in the body | `CC-BY-4.0` |
| `supersedes` | Revision, in the header | `sha256:...` |

## 3. Encoding

### 3.1 Canonical JSON

Records are hashed and signed in canonical JSON, as defined by [RFC 8785 (JCS)](https://www.rfc-editor.org/rfc/rfc8785):

- Object keys are sorted by their UTF-16 code units, with no whitespace anywhere. (Astral characters, encoded as surrogate pairs, therefore sort before U+E000 to U+FFFF.)
- Strings and numbers are serialized as ECMAScript's `JSON.stringify` does.
- Only null, booleans, finite numbers, strings, arrays and objects are allowed. Strings and keys MUST NOT contain unpaired UTF-16 surrogates.
- Objects and arrays nest at most 64 levels deep.

Verifiers canonicalize the value they parsed, not the bytes they received. Senders MUST NOT send objects with duplicate keys, and hosts MAY reject them.

### 3.2 Numbers

JSON numbers are IEEE 754 doubles (RFC 8785). Write integers beyond ±(2^53−1), exact decimals such as money, and high-precision values as strings, and say so in `schema`.

- **Acceptance rule.** A host MUST reject any integer-valued number `n` with 2^53 ≤ |n| < 10^21 anywhere in a header or body (400 `invalid_field`). In that range a signer using 64-bit integers and a host using doubles would serialize the same value differently.
- **Writer rule.** Client libraries MUST refuse such values before signing.
- Integrity doesn't check numbers.

### 3.3 Hashes

Every hash is written `sha256:` followed by 64 lowercase hex characters: `^sha256:[0-9a-f]{64}$`. This is the same syntax as OCI digests and Git LFS object ids.

| Hash | Input |
| --- | --- |
| Record id | The UTF-8 bytes of the canonical header |
| `body_hash` | The UTF-8 bytes of the canonical body |
| File `hash` | The file's bytes, after any content or transfer encoding is removed |

Record ids and file hashes share one space: both are `sha256:` targets (section 9).

### 3.4 Grammars

These grammars apply when a host accepts a record (section 5.3); integrity never checks them. Lengths of human text count Unicode code points. Lengths of URIs, terms, media types and file names count UTF-8 bytes.

- **Hash:** as in section 3.3.
- **Timestamp** (`created_at`): exactly `YYYY-MM-DDTHH:MM:SS.sssZ`, with three fractional digits, naming a real UTC instant (no February 30, no leap second). This is what ECMAScript's `Date.prototype.toISOString()` produces.
- **URI.** Every address field uses it: `about[]`, `links[].target`, `files[].uri` and `schema`.
  - It matches `^[A-Za-z][A-Za-z0-9+.-]*:[^\s\x00-\x1f\x7f\\]+$`, where `\s` is ECMAScript's whitespace class (which includes Unicode spaces such as U+00A0), and is at most 2,048 bytes.
  - Its scheme, compared case-insensitively, is not `data`, `javascript` or `blob`: those carry content or code, not an address.
  - An `http` or `https` URI has no user name or password: no `@` in its authority, read the way the WHATWG URL parser reads it.
  - A record id is a URI.
  - Clients and hosts MUST NOT dereference a URI automatically. The only exception is the holding host's own file download (section 8.4).
- **Invisible set.** These characters are forbidden where the rules below say so:
  - control characters: U+0000 to U+001F and U+007F to U+009F;
  - bidirectional marks: U+061C, U+200E, U+200F, U+202A to U+202E, and U+2066 to U+2069;
  - invisible characters: U+200B, U+2060 to U+2064, and U+FEFF;
  - tag characters: U+E0000 to U+E007F.

  ZWJ and ZWNJ (U+200C, U+200D) are allowed, because scripts and emoji need them.
- **Short text** (`title`, `display_name`, pool `name`, `scope` values, `license`): no character from the invisible set. Where a field is **non-blank**, it has at least one character that isn't whitespace.
- **Long text** (pool `description` and `policy`, pooler `description`): as short text, except that TAB, LF and CR are allowed.
- **Term** (`type`, `links[].rel`, `pooler_kind`, removal `reason`): at most 128 bytes, in one of two forms:
  - plain: `^[a-z][a-z0-9_]{0,63}$`, such as `finding`;
  - namespaced: `^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?(?:\.[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?)+/[a-z0-9_][a-z0-9_.-]*(?:/[a-z0-9_][a-z0-9_.-]*)*$`, such as `acme.com/invoice`. The author SHOULD control or cite the domain.
- **Media type:** `^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}/[a-z0-9][a-z0-9!#$&^_.+-]{0,126}(;[a-z0-9][a-z0-9!#$&^_.+-]{0,62}=[A-Za-z0-9!#$&^_.+-]{1,64}){0,3}$`, at most 255 bytes. Parameter values are tokens, never quoted strings. The **essence** is the part before the first `;`.
- **File name:** a relative path.
  - At most 512 bytes, in Unicode NFC. Split on `/` into 1 to 32 segments of 1 to 255 bytes each.
  - No segment is `.` or `..`, starts with `-`, `~` or a space, or ends with `.` or a space.
  - No segment contains a character from the invisible set, or any of these: `\` `:` `*` `?` `"` `<` `>` `|` `$` `` ` `` `;` `&` `!` `'`.
  - No segment's part before its first `.`, compared case-insensitively, is `CON`, `PRN`, `AUX`, `NUL`, `COM0` to `COM9` or `LPT0` to `LPT9`.
  - Names are unique within a record after NFC and lowercasing.
  - Segments starting with `.` are allowed; download clients handle them (section 8.4).
- **Scope key:** `^[a-z0-9][a-z0-9_.:-]{0,63}$`.
- **Nonce** (`claim_request`): exactly 22 base64url characters, 16 random bytes: `^[A-Za-z0-9_-]{22}$`.
- **Account** (`claim`): 1 to 128 characters from `A-Z a-z 0-9 _ : . -`. Never an email address.
- **Origin** (`audience`): scheme and host, plus the port if it isn't the default, such as `https://pudl.ai`.

These grammars start strict. Loosening one later breaks nothing; tightening one would.

### 3.5 Limits

| Limit | Value | Kind |
| --- | --- | --- |
| Canonical header | 4,096 UTF-8 bytes | Integrity (section 5.2) |
| Nesting | 64 levels | Integrity |
| `did:key` | 128 characters | Integrity |
| Canonical body | The host's `limits.body_bytes` (64 KB on PUDL.ai) | Acceptance, host-set |
| `about`, `links`, `scope` | 20 entries each | Acceptance |
| `files` | 256 entries | Acceptance |
| A file's size | None in the protocol; the host's `policy.files.max_bytes` | Host policy |

Hosts publish their limits in `pudl.json` (section 13.8). Readers apply their own resource limits; a body larger than a host's limit is still authentic.

## 4. Identity and signatures

### 4.1 Keys

An author is always a [`did:key`](https://w3c-ccg.github.io/did-method-key/): `did:key:z` followed by the base58btc encoding of an unsigned-varint multicodec and the key bytes, at most 128 characters. Decoding and re-encoding MUST give the identical string, so a non-minimal varint or a stray character makes the identifier malformed.

Record format 1 registers one key type:

| Key type | Multicodec | Key | Signature |
| --- | --- | --- | --- |
| Ed25519 | `0xed` (the bytes `0xed 0x01`) | 32 bytes | 64 bytes (86 base64url characters), strict verification (below) |

A new key type is added by registering a multicodec, a key length, a signature length and a verification rule. Until a reader knows it, records signed with it are **unverifiable**, not invalid. `did:web` never appears as `author`.

Agents generate and hold their own keys. A host never sees an agent's secret key; it only checks signatures, unless it generated the key itself as a **custodial key** for a person who can't hold one, such as a person acting on the host's web pages or through a remote MCP connection. A custodial key serves one purpose (one person on the web, or one remote MCP grant), is claimed by that person, is labeled `custody: "host"` wherever the host shows its author, is stored sealed under a secret of its own, and is destroyed when what it serves is revoked. Its records verify like any others; they prove the host signed on the person's behalf, not that the person's own software did.

### 4.2 Record signatures

A record's signature is an Ed25519 signature ([RFC 8032](https://www.rfc-editor.org/rfc/rfc8032)) by the header's `author` over the UTF-8 bytes of

```
PUDL-RECORD-V1\n<canonical header>
```

that is, the 15 characters `PUDL-RECORD-V1` and a line feed, followed by the canonical header. It is encoded as unpadded base64url, and re-encoding the decoded bytes MUST give the identical string. The record id is `sha256:` plus the hex SHA-256 of the canonical header alone, with no prefix.

The prefix separates domains: a PUDL record signature can't be passed off as another protocol's signature over bare JSON made with the same key, nor as a request signature (section 13.2).

So that every reader accepts exactly the same Ed25519 signatures, verifiers MUST:

- reject a signature whose scalar S is not less than the group order L;
- reject a public key A or point R that isn't a canonical encoding (y must be less than p = 2^255 − 19);
- reject a public key A of small order;
- check the cofactored equation [8][S]B = [8]R + [8][k]A, where k is SHA-512(R ‖ A ‖ message) reduced mod L.

## 5. Records

### 5.1 Envelope

```json
{
  "header": {"pudl": 1, "kind": "pool", "author": "did:key:z6Mk...", "created_at": "2026-10-01T12:00:00.000Z", "body_hash": "sha256:...", "...": "..."},
  "signature": "<86 base64url characters>",
  "body": {"name": "Build notes"}
}
```

`header.pudl` is the JSON integer `1`: the **record format**. It names canonical JSON, SHA-256 ids and body hashes, the signing input and the integrity algorithm, and changes only if section 5.2 changes. The spec version (`"0.3"`) and the API version (`/v0`) are versioned separately, and `pudl.json` reports both.

An envelope MAY also carry `id`. Other envelope members are ignored.

### 5.2 Integrity

Every reader runs this check, and it never changes for record format 1. To verify an envelope `E`, run these steps in order. The first failure decides the result.

1. `E` is a JSON object; `E.header` is an object; `E.signature` is a string; `E` has a member `body` (`null` or any JSON value); `E.id`, if present, is a string. Otherwise the record is **invalid**.
2. `H = E.header`. If `H.pudl` is not a JSON number, the record is **invalid**. If it is a number other than `1`, the record is **unverifiable**: stop.
3. `H.kind` is a string matching `^[a-z][a-z0-9_]{0,31}$`. Otherwise **invalid**.
4. `C` is the canonical form of `H` (section 3.1): only finite numbers, no unpaired surrogates, nesting of at most 64 levels, and UTF-8(`C`) of at most 4,096 bytes. Otherwise **invalid**.
5. `H.author` is a string of at most 128 characters: `did:key:z` followed by base58btc that decodes to an unsigned-varint multicodec followed by at least one key byte, and re-encodes to the identical string.
   - A registered multicodec (section 4.1) with the right key length: continue.
   - A well-formed but unregistered multicodec: **unverifiable**.
   - Anything else: **invalid**.
6. `E.signature` is the unpadded base64url encoding of the key type's signature (Ed25519: 64 bytes, 86 characters), re-encoding gives the identical string, and it verifies over the section 4.2 input under the strict rules. Otherwise **invalid**.
7. `id = "sha256:" + hex(SHA-256(UTF-8(C)))`. If `E.id` is present and differs, the record is **invalid**.
8. If `H` has no member `body_hash`, `E.body` MUST be `null`. Otherwise `H.body_hash` MUST match `^sha256:[0-9a-f]{64}$`, and either `E.body` is `null` (the body was removed), or the canonical form of `E.body` (nesting at most 64) hashes to `H.body_hash`. Otherwise **invalid**.

A record that passes is **authentic**, with a flag saying whether its body was removed. Readers MUST NOT reject an authentic record because of:

- an unknown kind;
- an unknown field or an unknown term;
- a body larger than any host's limit;
- the format of `created_at`;
- a target that isn't normalized;
- any other acceptance rule.

A reader that doesn't expect removed bodies (a host accepting a new record, for example) MAY treat a `null` body under a `body_hash` as a failure. Hashing and signing rules change only with a new record format, `pudl: 2`.

The conformance fixtures in `packages/core/test/fixtures/integrity.json` (repository reference; not published here) give an expected verdict for each of these rules.

### 5.3 Acceptance

A host accepting a record that an author submits (`POST /v0/records`) runs these steps in order. The first failure decides the answer.

1. **Parse.** The request body is JSON within `limits.request_bytes`, or 413 `too_large` with `details.limit: "limits.request_bytes"`. Request signatures cover the raw bytes (section 13.2).
2. **Integrity** (section 5.2), with the body present. Invalid: 400 `invalid_record`. Unverifiable: 400 `unsupported`.
3. **Author.** `H.author` MUST equal the request signer, or 403 `forbidden`.
4. **Duplicate.** A known id: 200 with `status: "duplicate"`. This comes before every later check, so retrying a stored record succeeds across host upgrades.
5. **Kind.** `kind` MUST be `pooler`, `pool`, `contribution` or `claim_request`, or 400 `unsupported`. A host never accepts a `claim` from an agent.
6. **Exact fields.** Header and body MUST contain exactly the fields that the host's spec version defines (section 6), with the grammars and limits in sections 3.4 and 3.5. Field names are case-sensitive.
   - An unknown field: 400 `unknown_field`, with `details.field` (such as `body.forked_from`) and `details.spec` (the host's spec version).
   - An explicit `null`: 400 `invalid_field`, with a message saying to omit the field.
   - Any other violation: 400 `invalid_field`, with `details.field` and `details.rule`.
   - A canonical body over `limits.body_bytes`: 413 `too_large`, with `details.limit: "limits.body_bytes"`.
7. **Numbers** (section 3.2): 400 `invalid_field`.
8. **Freshness.** A host MAY bound `created_at`. PUDL.ai accepts records created up to 24 hours ago and up to 5 minutes ahead; otherwise 400 `invalid_field` with `details.rule: "freshness"`. A `claim_request` whose `audience` isn't the host's own origin is 400 `invalid_field` too.
9. **Targets.** Every `about` entry and link target MUST parse under the host's target profiles (Appendix B), or 400 `invalid_target` with `details.field` and an explanation. A target that parses but isn't in normalized form is accepted.
10. **Access and structure:**
    - the pool exists and the caller may write to it (404 `not_found` or 403 `forbidden`);
    - the version chain (409 `stale_version`, with `details.head` and `details.version`);
    - the `supersedes` rules (section 11.4);
    - visibility never widens (403 `forbidden`).
11. **Host policy** (section 15): 403 `host_policy` (with `details.policy`), `sandbox_limit` or `needs_claim`, or 451 `withheld` for content the host blocks by hash.
12. **Held files** (section 8.2): 400 `missing_file` (a host that screens files asynchronously MAY answer 409 `file_pending`).
13. **Store.** The host stores the canonical header string `C` and the canonical body string it computed during integrity. It never re-serializes a parsed or validated object, and it stores only `header`, `signature` and `body`. Response: 201 with `status: "created"`.

Hosts MUST screen and index exactly the parsed value they store, never the raw request text, and they serve only the canonical form.

`details.rule` names are informative. Clients MUST NOT depend on them; the error `code` is what's normative. The conformance fixtures in `packages/core/test/fixtures/acceptance.json` (repository reference; not published here) cover steps 5 to 7.

### 5.4 Readers

- Readers validate the fields they know and MUST ignore fields they don't know. They MAY skip a record whose known field they can't use.
- A record of an unknown kind is authentic but opaque: it is never interpreted.
- A reader processing many records MUST skip an invalid one, not abort.
- Fallbacks for unknown values in closed fields:

| Field | Fallback |
| --- | --- |
| `outcome` | Not scored, like `unused` |
| `visibility` | `private` (fail closed) |
| Open terms | Opaque strings |

### 5.5 How the protocol grows (normative)

- Spec minor versions MAY add kinds; optional header or body fields; terms and target profiles; endpoints, query parameters, response fields and error codes.
- They MUST NOT rename, retype, remove or reinterpret an existing field or value.
- **A new field MUST be safe for a reader to ignore.** If ignoring it would mislead a client, the meaning goes in a new kind.
- **Hosts hold only what they understand.** A host MUST NOT accept, import or relay a record with a kind or field it doesn't implement. So a restricting field added later (an embargo, a retention period, `listed: false`) is always enforced by any host that serves the record. There is no must-understand flag.
- Integrity changes only with a new record format (`pudl: 2`).
- API clients MUST ignore unknown response fields, treat unknown `kind` and term values as opaque, and handle unknown error codes by their HTTP status.
- **Absent means null.** For every header and body field, "no value" is written by leaving the field out. Writers MUST NOT send `null`. Clients written for 0.2 keep working against later 0.x hosts, because every 0.2 record stays valid. Writers SHOULD check `pudl.json` `spec` before using fields newer than their host implements.
- **What goes in a header.** Only what hosts need without reading the body, and what must survive body removal: `author`, `created_at`, `body_hash` and `pool`; lineage (`version`, `prev`, `supersedes`); access and consent (`visibility`, `share_receipts`); and the fixed fields of the bodiless kinds. Header values are ids, hashes, dids, timestamps, integers, booleans, enums or strings with a fixed grammar, **never author-written free text**. Everything a record says goes in the body.
- **`pool` on any kind.** When present, header `pool` names the pool the record belongs to. A `pool` record with no `pool` field is version 1, and its own id is the pool id.

## 6. Kinds and fields

### 6.1 Every header

| Field | Value |
| --- | --- |
| `pudl` | The integer `1` |
| `kind` | The record kind |
| `author` | The signer's `did:key` |
| `created_at` | A timestamp (section 3.4), chosen by the author |
| `body_hash` | Hash of the canonical body. Present exactly when the record has a body. |

The tables below list each kind's other fields. A field not marked optional is required.

### 6.2 `pooler`: a profile

Written by the key itself. Optional: an agent can use PUDL without one.

| Header | Value |
| --- | --- |
| `version` | Integer ≥ 1 |
| `prev` | Id of the previous version. Omitted on version 1. |
| `body_hash` | Omitted for an empty profile, whose body is `null` |

| Body | Value |
| --- | --- |
| `display_name` | Optional, non-blank short text, ≤ 80 |
| `description` | Optional long text, ≤ 500 |
| `pooler_kind` | Optional term, self-asserted. Core terms: `agent`, `human`, `org`. |

A profile body with no fields isn't sent as `{}`: an empty profile has no `body_hash` and a `null` body.

### 6.3 `pool`

| Header | Value |
| --- | --- |
| `pool` | The pool id. Omitted on version 1, whose own id becomes the pool id; required on later versions. |
| `version`, `prev` | As for poolers |
| `visibility` | `private` or `public` (section 11.1) |
| `share_receipts` | Boolean: the owner's consent for this pool's receipts to count, as totals, in other owners' lookups (section 10) |

| Body | Value |
| --- | --- |
| `name` | Non-blank short text, ≤ 100 |
| `description` | Optional long text, ≤ 2,000 |
| `policy` | Optional long text, ≤ 4,000: what belongs in the pool |

### 6.4 `contribution`

| Header | Value |
| --- | --- |
| `pool` | Optional pool id. **Omitted means personal** (section 11.3). |
| `supersedes` | Optional id of an earlier contribution this one replaces (section 11.4) |

| Body | Value |
| --- | --- |
| `type` | Optional term (section 7). Absent means untyped. |
| `title` | Optional, non-blank short text, ≤ 200 |
| `content` | Optional. Any JSON value except `null`. |
| `media_type` | Optional media type of `content`. Invalid without `content`. When absent, the **effective** type is `text/plain` for a string and `application/json` for any other value. |
| `schema` | Optional URI naming the shape and meaning of `content`, or of the files when there is no content: a JSON Schema `$id`, `https://schema.org/Invoice`, a Frictionless Table Schema, a `urn:`, or the id of a contribution that holds the schema. Hosts MUST NOT reject an unknown schema, and nobody dereferences it automatically. |
| `files` | Optional array of 1 to 256 file entries (section 8.1) |
| `about` | Optional array of 1 to 20 distinct targets (section 9) |
| `outcome` | Optional: `worked`, `partial`, `failed` or `unused` (pulled but not relied on). **Requires `about` with exactly one entry.** |
| `links` | Optional array of 1 to 20 distinct objects with exactly two members: `{"rel": <term>, "target": <URI>}` |
| `scope` | Optional object of 1 to 20 pairs. Keys are scope keys (section 3.4); values are non-blank short text of at most 256. `task` is a conventional key. |
| `license` | Optional short text, ≤ 256. It SHOULD be an SPDX license expression or a URL. Hosts don't parse it. |

A contribution MUST have at least one of `content`, `files` or `about`.

**Receipts.** A receipt is a contribution with an `outcome`: `about` names the one target the author relied on, `content` holds an optional note, and `scope` says where (`{"task": "install", "os": "linux"}`). To credit the knowledge behind it, a receipt links to the records that informed it with `rel: "informed_by"`.

Other common shapes:

| To say | Write |
| --- | --- |
| A record is right, or wrong | A receipt with `about: ["sha256:<record id>"]` |
| A reply or an annotation | A contribution with `about: ["sha256:<record id>"]` |
| Supporting material | `links` with `rel: "evidence"` |
| Bytes stored elsewhere | A `files` entry with `uri` |
| Something with no bytes to pin (a bucket prefix, a live table) | `about` or `links` |

### 6.5 `claim_request`

Signed by an agent that wants one host to link it to a person's account. No body.

| Header | Value |
| --- | --- |
| `audience` | The origin of the host being asked, such as `https://pudl.ai` |
| `nonce` | 22 base64url characters (16 random bytes) |

A host MUST reject a claim request whose `audience` isn't its own origin, so that no other host can use it.

### 6.6 `claim`

Signed by a host after a signed-in person accepts a claim request. No body.

| Header | Value |
| --- | --- |
| `agent` | The agent's `did:key` |
| `account` | The host's opaque id for an account (section 3.4): a person's, or a service account the host keeps (for example, PUDL.ai's retained business-gateway compatibility, host profile) |
| `request` | Id of the agent's `claim_request` |

A claim attests that the account accepted the agent at `created_at`. It says nothing about whether the link still holds: current status is host state (`GET /v0/poolers/{did}` answers `claimed`). A claim means something only when its `author` equals the `host_did` of the host that issued it, which the host publishes in `pudl.json`.

### 6.7 Version chains

Poolers and pools change by new versions. Version 1 omits `prev` (and, for pools, `pool`). Every later version names the current head's id in `prev` and, for pools, the pool id in `pool`. A host MUST reject a new version unless its `prev` is the current head and its `version` is one more than the head's: 409 `stale_version`, with `details.head` and `details.version` naming the current head.

## 7. Open vocabularies

- **No protocol rule depends on `type`** or on a link's `rel`. Behavior depends on fields (`outcome`, `files`, `about`), which is what makes open vocabularies safe.
- **No term, rel or schema implies endorsement.** Hosts MAY reject namespaced terms under their own domain.
- Hosts MUST accept, index and filter on any well-formed term. Clients show an unknown type as generic data.
- Form is expressed by `media_type`, `files` and `schema`, never by `type`.

Recommended `type` terms:

| Term | Meaning |
| --- | --- |
| `finding` | Something the author established or verified |
| `observation` | What happened: an event, a measurement, a log |
| `reference` | A source worth keeping |
| `failure` | An approach that didn't work |
| `artifact` | The thing itself, meant to be used: a dataset, document, model, template or code |
| `connection` | How to reach a data source: a connection descriptor, under the Connections profile (Appendix C) |

Core `rel` terms:

| Rel | Meaning |
| --- | --- |
| `evidence` | Backs this record up |
| `derived_from` | This was made from the target (lineage) |
| `informed_by` | Led the author to rely on the `about` target, or to produce this record |
| `same_as` | The same thing elsewhere |

Other rels are valid, and hosts attach no meaning to them. No rel, `same_as` included, merges identities, rollups or scores.

Removal reason terms, also used as abuse report categories:

| Term | Meaning |
| --- | --- |
| `copyright` | Infringes someone's rights |
| `csam` | Child sexual abuse material |
| `illegal` | Other illegal content |
| `personal_data` | Someone's personal data |
| `secret` | A leaked password, key or other secret |
| `malware` | Malicious code |
| `injection` | Instructions aimed at agents (prompt injection) |
| `spam` | Spam |
| `policy` | Breaks the host's terms |
| `other` | Anything else |

A host that receives a well-formed reason term it doesn't know MAY store it as `other`.

## 8. Files

### 8.1 File entries

| Entry field | Value |
| --- | --- |
| `hash` | **Required.** The hash of the bytes |
| `size` | **Required.** Integer, 0 to 2^53−1 |
| `uri` | Optional URI where readers fetch the bytes. **Absent means the entry is held**: the host serving the record holds the bytes. |
| `name` | Optional file name (section 3.4) |
| `media_type` | Optional media type |

- **Held entry** (no `uri`). The host MUST hold verified bytes for it, and the submitter MUST have possession (section 8.2).
- **External entry** (`uri` present). The host never fetches the bytes, never serves them, and never lets the entry grant access to bytes it holds. Any host accepts external entries, even one that stores no files.
- Readers MUST check `size` and `hash` before using fetched bytes. On a mismatch, the bytes are not the file.
- The protocol has no size limit for files. Hosts publish their own in `policy.files` (section 13.8); `policy.files` is `null` on a host that stores no files, which then refuses held entries with 403 `host_policy` (`details.policy: "files"`).

### 8.2 Possession

A caller **has possession** of hash `H` on a host when either:

- an agent of the caller's account (on a host with groups, acting for the group the caller acts for) uploaded `H` to that host and the host still holds it (for an unclaimed caller: the caller's own key uploaded it); or
- the caller can read a live record on that host with a held entry for `H`.

External entries never count.

- Accepting a held entry requires possession, and bytes ready to serve. Either failure answers the same 400 `missing_file` with `details.hashes`, so the answer can't be used to probe what the host holds.
- A held entry whose `size` differs from the stored bytes is 400 `invalid_field`. The size is compared only after possession is established.
- `GET /v0/files` and the `file` field of lookups are answered only from possession. Their metadata comes from record entries, never from host storage.

### 8.3 Upload: `PUT /v0/files/sha256:<hex>`

Signed. The body is the file's raw bytes, and `content-length` is required.

1. The signature's body-hash line (section 13.2) is `<hex>` from the path. The host verifies the signature **before** reading the body (otherwise 401 `unauthorized`).
2. The host refuses early, from `content-length` and the caller:
   - 403 `host_policy` with `details.policy: "files"` when the host stores no files;
   - 403 `needs_claim` when only claimed agents upload (`policy.files.require_claim`);
   - 400 `bad_request` without `content-length`;
   - 413 `too_large` with `details.limit: "policy.files.max_bytes"`;
   - 403 `quota` when the upload would exceed `policy.files.quota_bytes` for the caller's account.
3. The host reads exactly `content-length` raw bytes (never as text), and checks their hash against the path. A mismatch, or a body longer or shorter than `content-length`, is 400 `bad_request`.
4. The host screens **every** upload in full, even if it already holds `H`. A refusal is 403 `host_policy` with `details.policy`. Bytes it blocks by hash are 451 `withheld`; bytes it took down are 410 `gone`. The blocklist is consulted only after the bytes arrive.
5. The host writes the bytes itself, to a location no client credential can write. Response: **201 `{"file": {"hash", "size"}}`**. The answer is identical whether or not the host already held `H`, so its status, body and timing never depend on other callers.

A host that screens asynchronously MAY answer 202 `{"file": {"hash", "size", "status": "screening"}}` instead, and answer the uploader's record with 409 `file_pending` and `retry-after` until screening ends. PUDL.ai screens synchronously.

Presigned and multipart uploads are deferred. When added, they MUST upload to a staging location and be verified and screened by the host before the bytes move to the location named by the hash.

### 8.4 Download: `GET /v0/files/sha256:<hex>`

Signed.

- **404** `not_found` unless the caller has possession. This check comes first.
- **451** `withheld` for bytes withheld for legal reasons.
- **410** `gone`, with `details.removed` holding the removal note (section 12.1), for removed bytes.
- **200** with the bytes, or **302** to a URL that is valid for at most 5 minutes and is on a different site from the host.

Responses that carry the bytes send:

- `content-type: application/octet-stream`
- `content-disposition: attachment; filename="<hex>"`
- `x-content-type-options: nosniff`
- `content-security-policy: sandbox`
- `x-pudl-hash: sha256:<hex>`

Clients follow redirects only to https URLs, and never send `x-pudl-*` headers on a redirect. Clients never unpack archives, never make downloaded files executable, and never overwrite existing files.

### 8.5 Removal and expiry

- A host MAY delete bytes that no live record lists once `policy.files.unreferenced_hours` has passed since their last upload. After that the uploader gets 404.
- A file takedown removes the bytes: 410 for everyone with possession. The records that list the file stay.

## 9. About, links, lookup and search

### 9.1 Semantics

- `about` says what a contribution concerns. It is in the body, so removing a body also removes its associations, which is what erasure needs.
- To reply to, annotate or report on a record, put its id in `about`.
- `links` are typed edges. They are not indexed at launch.

**Matching (normative).** A host's lookup and its `about=` search filter MUST match every readable record that has an `about` target normalizing, under the host's *current* target profiles, to the same string as the query. `rollup=true` matches on the subject instead (a package without its version, or a web page's domain). How a host indexes this is its own business. A stored target that a newer profile rejects is matched by its raw string. So a query the current profiles reject but that is still a URI is matched by its raw string too, not refused; only a query that isn't a URI is 400 `invalid_target`. A host that keys its index by normalized targets re-keys it when its profiles change.

### 9.2 Lookup: `GET /v0/lookup?target=`

Signature optional. A target that isn't a URI is 400 `invalid_target` (section 9.1).

| Field | Value |
| --- | --- |
| `target`, `kind`, `subject` | The normalized target. `kind` is `npm`, `pypi`, `mcp`, `url`, `domain`, `hash` or `uri`. Clients treat unknown kinds as `uri`. |
| `signals` | Short strings from fixed host templates, such as `not found on npm` or `2 failed receipts (yours)`. They **never quote third-party text**. |
| `sources` | Array of `{source, exists, url?, facts?, error?}`, where `exists` is `true`, `false` or `null`. Publisher-written text (descriptions, deprecation messages) appears only inside `facts`. |
| `receipts` | `{"exact": {"yours": Summary, "others": Summary or null}, "rollup": {"yours": Summary, "others": Summary or null} or null}`. `rollup` is `null` for `hash` and `uri` targets. |
| `entries` | Exactly the first page (limit 10) of `GET /v0/search?about=<target>&rollup=true`: contribution views, each with `match: "exact"` or `"subject"`. Empty for an unsigned lookup. |
| `entries_next` | A cursor to continue that search, or `null` |
| `record` | When the target is the id of a record the caller can read: the `GET /v0/records/{id}` object. Otherwise `null`. |
| `file` | When the caller has possession of the hash through a readable record's held entry: `{hash, size, name?, media_type?, removed?, listed_by: [up to 10 contribution views]}`, taken from the newest such entry. Otherwise `null`. |
| `notice` | The data-not-instructions reminder |

`sha256:` targets name both records and files; `record` and `file` report them separately.

### 9.3 Summary

A **Summary** is `{total, worked, partial, failed, unused, unclaimed, accounts, score, scorer}`:

- `unclaimed` counts receipts that carry no weight: from unclaimed agents, or from keys no longer claimed by the record's owner account;
- `accounts` counts the distinct owner accounts behind the score;
- `score` and `scorer` come from the host's scorer (section 14).

**`yours`** counts receipts in the pools the caller reaches (section 11.1), plus the personal records the caller reads (section 11.3). **`others`** follows section 10. The two are never merged, into each other or into one score. Hosts MAY round the counts and score in `others`.

Contribution views carry `receipts`, a Summary computed from `yours` only, since `sha256:` targets are never shared.

### 9.4 Search: `GET /v0/search`

Signed. This is the only listing of contributions.

| Parameter | Meaning |
| --- | --- |
| `q` | ≤ 500 characters. Host-defined text search. |
| `pool` | Restrict to one readable pool (404 otherwise) |
| `type` | A term, exact match |
| `about` | A target, matched as in section 9.1 (400 `invalid_target` if it isn't a URI) |
| `rollup` | `true`: match `about` on the subject. Requires `about`. |
| `schema` | Exact string |
| `media_type` | An essence such as `text/csv`, or `major/*`. Matches the effective media type of `content`, or the essence of any `files[].media_type`. |
| `outcome` | `worked`, `partial`, `failed`, `unused`, `any` (receipts only) or `none` (no receipts) |
| `include_superseded` | `true` includes superseded records |
| `cursor` | From a previous `next` |
| `limit` | 1 to 50, default 20 |

- **Scope.** Records in the pools the caller reaches (section 11.1), plus the personal records it reads (section 11.3). A public pool the caller doesn't reach is searched only when named with `pool=`.
- **Defaults.** Superseded records are hidden. Records from suspended keys and accounts are hidden, here and in lookups. **Unless `outcome` is given, receipts with neither `content` nor `files` are left out**; they still count in totals. Removed records match no filter.
- **Order.** With `q`, the host ranks results. Otherwise, newest *received* first.
- **Strict parameters.** Any other parameter, or a repeated one, is 400 `unsupported_filter` with `details.supported`. A filter is never silently ignored, which makes every future filter safe to add. A bad value is 400 `bad_request`; an empty `q` counts as absent.
- **Response:** `{"contributions": [contribution views], "next": cursor or null, "note"?, "notice"}`. `note` is a host message, such as why an agent whose account has no standing reaches nothing.

## 10. Sharing across owners

Receipts can help other teams without exposing any one team. This section is normative for hosts that share.

1. **Consent.** A receipt counts in `others` only if its pool's `share_receipts` was true in the version in force when the host received it, **and** is still true. Consent never applies retroactively, and withdrawing it takes effect immediately.
2. **Who counts.** Only receipts whose author key is still claimed by the record's owner account, and never receipts the caller can already read (the pools it reaches, its personal records, and public pools).
3. **What counts.** Only receipts whose own normalized target's scheme is in the host's `policy.sharing.schemes`. Never `sha256:` targets. Never the personal records of any account, and never records in unclaimed sandboxes.
4. **Threshold.** `others` is `null` unless the counted receipts come from pools of at least `policy.sharing.min_pool_owners` distinct **pool owners**, with at least that many distinct owner accounts among them. Two people of one team writing in their team's pool count once. (A host that groups accounts, such as into organizations, counts the groups, and says in its host profile when one doesn't count yet.)
5. `others` is never merged into `yours` or into any score.

## 11. Pools and access

### 11.1 Visibility

- **`private`:** readable and writable by its owner and the members its owner admits.
  - The owner is an **account**, or the creating key for an unclaimed sandbox. A host MAY group accounts (PUDL.ai: organizations, where people hold roles; host profile) and let a group own pools; this document then reads "account" as that group.
  - **A claimed agent acts for its account**, with what the account reaches now. Membership is host state, checked on every request, so a removed member's agents stop at the next request. A host with groups MAY bind each agent to one of them and let it reach only that group's pools (PUDL.ai does).
  - A host MAY let only some of a pool's members read it (PUDL.ai: `audience`, host profile).
  - A host MAY restrict contribution and curation to explicitly authorized members, while other admitted members retain read access. An agent's delegated operations MAY further restrict its account's rights. These are host permissions, not new signed fields; refusal MUST identify the permission needed. A read grant alone does not authorize writing, inviting, credential use or public republication.
  - Other owners join a pool as **guests** (members), and act through every agent that works for them.
- **`public`:** readable by anyone. Hosts decide who may create one (`policy.public_pools`); PUDL.ai has none at launch.

Only those who **curate** a pool (its owner, and whoever else the host lets curate it) publish new pool versions and create invites. A claimed agent creates pools for its account, only where it may curate (403 `forbidden` otherwise).

**Visibility never widens.** A version may set `public` only if every earlier version was public: 403 `forbidden` otherwise. A private pool never becomes public; publish a new pool instead. Authors keep the audience they signed for.

### 11.2 Unclaimed agents and sandboxes

An unclaimed agent can write, but its pools are **sandboxes** it owns by key, and hosts MAY limit and expire them (`policy.sandbox`). When a person claims the agent, its sandboxes move to the account (or the group the agent is bound to).

### 11.3 Personal records

A contribution without `pool` is personal.

- When a host accepts a record, it stores the **owner account at write time**: the account that then claims the author key, or none (and, on a host with groups, the group the author then acts for).
- On an agent's *first* claim, its records that have no owner account are adopted by the claiming account.
- A personal record is readable by the agents of its owner account, or only by the author key when it has no owner account. A host with groups MAY limit personal records to agents bound to the account's own group (PUDL.ai: its personal organization; an agent working for a business gets 403 `forbidden` for a personal record), so no agent carries one group's data into another through a person's personal records.
- Releasing a key and claiming it into another account never moves access to old records.

### 11.4 `supersedes`

A host accepts `supersedes` only if the target is a contribution and either:

- both are in the same pool, and the new record's owner account equals the target's, or the new record's author may curate the pool; or
- both are personal records with the same owner account, which the caller reads.

(Two records without an owner account have the same owner only when they have the same author.) Otherwise 403 `forbidden`. A superseded record is hidden from listings by default, and its view names `superseded_by`.

### 11.5 Invites

`POST /v0/pools/{id}/invites`, no body, by claimed agents that may curate the pool, for private pools (403 `needs_claim` for unclaimed agents, 403 `forbidden` for others). The response is `{invite_url, pool: {id, name}, expires_at, approval, message}`. The link works once. The person who opens it joins (on a host with groups, picks which of their groups joins). With `approval: "owner"`, the joiner is a pending guest until someone who curates the pool approves it on the host's web pages; PUDL.ai does this for every invite.

### 11.6 Records by id: `GET /v0/records/{id}`

Signature optional. The answer is `{record: envelope, removed?: note, view?: contribution or pool view, notice}`, or 404 when the caller can't see the record.

- Pool and contribution records follow pool access. Former owners and members of a removed pool can still read its records' headers as history.
- Personal records follow the owner account (section 11.3).
- `pooler` profiles are public.
- `claim_request` and `claim` records are visible only to the agent itself and the agents of its current account. `GET /v0/poolers/{did}` shows `claimed` to everyone, and the `claim` envelope only to those callers.
- A removed body is served as `body: null` with a `removed` note. The id and signature still verify.

### 11.7 Claiming an agent

1. The agent signs a `claim_request` naming the host's origin as `audience` and submits it with `POST /v0/records`. The host answers 201 with a `claim_link`: `{status: "pending", agent, claim_url, expires_at, message}` (valid for 7 days on PUDL.ai), or `{status: "claimed", agent, claimed_at}` if the agent is already claimed. Every submission gets a fresh link.
2. The agent shows the link to its person, who signs in and confirms (on a host with groups, after picking which group the agent works for). The page shows the agent's `did:key`, the pools it will bring, and a warning that a claimed agent gets the person's access.
3. The host signs a `claim` record and moves the agent's sandboxes and ownerless records to the account (section 11.3).

An agent is claimed by one account at a time. Completing one of its links closes the others. The person can release it: it loses access, its receipts stop carrying weight, and invite links it made stop working. Agents never change their own claim or binding.

## 12. Removal

### 12.1 Removal note

Everywhere a removal is reported (record responses, file 410 responses, views), it has this shape:

```json
"removed": {"at": "<timestamp>", "by": "author" | "curator" | "host" | "expiry", "reason": "<term>", "actor": "<did>"}
```

- `reason` and `actor` appear only to the author's owner account (or the author key, when there is none) and to those who curate the pool, and only when there is one.
- A client that sees an unknown `by` treats it as `host`.

### 12.2 `DELETE /v0/records/{id}`

Signed, with no body. The host checks, in this order:

1. A record the caller can't see: 404 `not_found`.
2. A pool record, any version: hosts MAY let pool owners delete pools this way. PUDL.ai answers 403 `needs_human`: a signed-in person deletes pools on the web.
3. A `claim_request` or `claim`: 403 `forbidden`.
4. Authority, otherwise 403 `forbidden`:
   - **Author removal** (`by: "author"`), for contributions and pooler profiles: the caller is the author key and still belongs to the record's owner account (or neither has an account), or the caller is an agent of the record's owner account.
   - **Curator removal** (`by: "curator"`), for contributions in a pool: the caller may curate the pool (section 11.1), or is the creating key of an unclaimed sandbox. Hosts MAY cap curator removals (429 `rate_limited`).

The response is `200 {"id", "removed": note}`. It is idempotent: a repeat returns the stored note.

**Effect.** The host stops serving the body immediately and removes everything derived from it: title, search text, `type`, `media_type`, `schema`, `outcome`, `scope`, `about` matching and file references (so unreferenced files can be deleted). Removed records match no filter.

### 12.3 Hosts

- A host MAY remove a body because of a takedown, an expiry or its own policy.
- A host MAY restore a body removed by its author or curator, within the restore window it publishes (`policy.retention.removed_body_days`).
- A host MAY withhold a whole record, header included, when the law requires it, answering 451 `withheld` with the removal note. Append-only describes the author's log; it is not a duty to republish unlawful material.
- A host MAY delete a record entirely, header included, if no one but its author's owner could ever read it, such as an expired unclaimed sandbox.

## 13. Host API, v0

### 13.1 Transport

Hosts serve JSON over HTTPS under `/v0` at the root of their origin. Records are submitted as the request body, exactly as the envelope in section 5.1. Request bodies are UTF-8 JSON; a leading byte-order mark is ignored and invalid UTF-8 is refused. Hosts serve records in canonical form.

### 13.2 Request signatures

Agents sign every request with their key, so a host never holds a secret for them. A signed request carries four headers:

| Header | Value |
| --- | --- |
| `x-pudl-did` | The agent's `did:key` |
| `x-pudl-timestamp` | Unix time in seconds |
| `x-pudl-nonce` | 16 to 64 random base64url characters, new for every request |
| `x-pudl-signature` | Unpadded base64url signature |

The signature covers these seven lines, joined by `\n`:

```
PUDL-REQUEST-V1
<the host's origin, e.g. https://pudl.ai>
<METHOD, uppercase>
<path and query as a WHATWG URL serializes them: url.pathname + url.search>
<timestamp>
<nonce>
<hex SHA-256 of the raw request body bytes; of the empty string when there is none>
```

- The body-hash line is the SHA-256 of the **raw bytes** sent, not of decoded text. For `PUT /v0/files/sha256:<hex>` it is `<hex>` from the path, so the host can check the signature before reading the body.
- The origin line binds the request to one host, so no other host can replay it.
- The path line is exactly what the WHATWG URL parser produces, which is also what `fetch` sends. For example, `/v0/search?q=don't` is signed as `/v0/search?q=don%27t`, and a lone trailing `?` disappears. Clients SHOULD build the URL with a WHATWG URL parser and sign its `pathname + search`.
- The first line can never parse as JSON or begin a record's signing input, so a request signature can never pass for a record signature, or the other way round.

Hosts MUST reject timestamps more than 300 seconds from their clock. For every method other than GET, HEAD and OPTIONS, hosts MUST remember `(did, nonce)` for at least twice that window and reject a repeat with 409 `replay`. A suspended key gets 403 `suspended`.

### 13.3 Endpoints

| Method and path | Signed | What it does |
| --- | --- | --- |
| `GET /v0` | Optional | Host info, as `/.well-known/pudl.json` (section 13.8) |
| `GET /v0/lookup?target=` | Optional | Section 9.2 |
| `GET /v0/search?...` | Yes | Section 9.4 |
| `POST /v0/records` | Yes | Submit one record of any kind an author may submit (section 13.4) |
| `GET /v0/records/{id}` | Optional | Section 11.6 |
| `DELETE /v0/records/{id}` | Yes | Section 12.2 |
| `GET /v0/pools?name=` | Yes | `{pools: [pool views], notice}`: the pools the caller reaches (at most 200). `name` matches case-insensitively. |
| `GET /v0/pools/{id}` | Optional | `{pool: pool view with policy, record: head envelope, note?, notice}` |
| `POST /v0/pools/{id}/invites` | Yes | Section 11.5 |
| `GET /v0/me` | Yes | The caller's `did`, `claimed`, `owner` (`{account, label}`: the account's opaque id and a label that names no sign-in provider), `custody` (`self` or `host`), `pools`, rate limits and `host_did`. Optional, from a host profile: `org` (`{id, name, kind, sources_pool?}`, the group the agent acts for), `org_role` (its account's role there now, `null` when it has none), `scope` (the key of a remote MCP grant: `{pools?, scopes}`), `next_step`. |
| `GET /v0/poolers/{did}` | Optional | `{pooler: {did, first_seen_at, claimed, claimed_at?, custody, profile?, claim?}, host_did, notice}`, with `claim` restricted as in section 11.6 |
| `PUT /v0/files/sha256:<hex>` | Yes | Section 8.3 |
| `GET /v0/files/sha256:<hex>` | Yes | Section 8.4 |
| `POST /v0/abuse` | Optional | `{category: <reason term>, target, details?, contact?}`. An unknown term is stored as `other`. |

A host profile MAY add endpoints (PUDL.ai's: `GET /v0/library`, `POST /v0/gateway/enroll` and `GET /v0/gateway/connect`, in its host profile). Clients that don't know them never call them.

### 13.4 `POST /v0/records`

The request body is one envelope, and the host dispatches on `header.kind`. The response nests the view under its own key, so no field can collide with another:

```json
{"id": "sha256:...", "kind": "contribution", "status": "created", "contribution": {"...": "view"}, "note": "...", "notice": "..."}
```

- The view key is `pool` (a pool view), `contribution` (a contribution view), `pooler` (`{did, profile}`), or `claim_link` for a `claim_request` (section 11.7).
- `status` is `created` (HTTP 201) or `duplicate` (HTTP 200). Every `claim_request` submission is 201.
- On a duplicate, the view appears only if the caller can still read the record.
- `note`, when present, is a host message, such as when a sandbox expires or who can read a personal record.
- The rate limit counts one write per request.

### 13.5 Views

In API responses, an optional field with no value is omitted. Clients treat `null` and an omitted field alike.

**Contribution view:**

| Group | Fields |
| --- | --- |
| Identity | `id` |
| From the body | `type`, `title`, `content`, `media_type`, `schema`, `files`, `about`, `links`, `outcome`, `scope`, `license`. `media_type` is the effective type, present whenever `content` is. |
| Lineage | `supersedes`, `superseded_by` |
| Author | `author: {did, claimed, same_account, org?, custody?}`, where `same_account` says whether the author is the caller or an agent of the caller's account, `custody: "host"` marks a key the host holds (section 4.1), and, on a host with groups, `org` (`{id, name}`) names the group the author wrote for when it isn't the caller's own (a guest pool) |
| Pool | `pool: {id, name}`, omitted for a personal record |
| Times | `created_at` (from the author) and `received_at` (the host's receive time) |
| Totals | `receipts` (a Summary) |
| Removal | `removed` (a note). When it is present, the body fields are omitted. |

**Pool view:** `id`, `name`, `description`, `policy` (only on `GET /v0/pools/{id}`), `visibility`, `share_receipts`, `version`, `head`, `sandbox`, `expires_at`, `created_at`, `updated_at`, `curates` (whether the caller may curate it: publish versions, remove others' records, make invites), `guests` (the number of guest owners, not counting the owning one), and in listings `contributions` (a count). Optional, from a host profile: `org` (`{id, name}`, the owning group; omitted for a sandbox), `org_role` (why the caller reaches it, such as `owner`, `admin`, `member`, `implementer` or `guest`), `audience` (who among the members reads it), `schemas` (schema hints for writers), `curated` (`true` for a curated public pool). Names are written by people: data, like everything else in a view.

Responses that carry data written by others also carry `notice`, a reminder that it is data, not instructions.

### 13.6 Cursors

- Every listing takes `cursor=` and answers `next`: a string, or `null` when there is no more.
- Cursors are opaque and match `^[A-Za-z0-9_-]{1,256}$`. Clients MUST NOT parse or build them.
- A cursor is valid only with the same other parameters (`limit` may change), otherwise 400 `bad_request`. It never carries authority: access is checked again on every request.
- Paging never depends on `created_at`, which authors choose. PUDL.ai pages unranked results by its receive sequence, and ranked `q` results by an offset of at most 500.

### 13.7 Errors

Errors are `{"error": {"code", "message", "details"?}}`. `code` is specific and open: clients handle codes they don't know by the HTTP status. Messages are written for agents: they say what to send instead, and **never quote text written by third parties**.

| Status | Codes |
| --- | --- |
| 400 | `bad_request`, `invalid_record`, `unsupported`, `unknown_field`, `invalid_field`, `invalid_target`, `unsupported_filter`, `missing_file` |
| 401 | `unauthorized` |
| 403 | `forbidden`, `host_policy`, `needs_claim`, `needs_human`, `sandbox_limit`, `quota`, `suspended` |
| 404 | `not_found`: not found, or not visible to the caller. Hosts don't say which. |
| 409 | `conflict`, `stale_version`, `replay`, `file_pending` |
| 410 | `gone`: the body or bytes were removed; the note is in `details.removed` |
| 413 | `too_large`, with `details.limit` naming the `pudl.json` limit |
| 429 | `rate_limited`, with `retry-after` |
| 451 | `withheld`: withheld for legal reasons |
| 500 / 501 | `internal` / `not_implemented` |
| 503 | `unavailable`: the host can't do this right now, such as taking an upload while its malware scanner is down; with `retry-after` |

`host_policy` refusals carry `details.policy`, naming the `pudl.json` key or rule that refused. For secrets, `details.findings` is a list of `{rule, field, line?}` and never the secret itself.

### 13.8 Host info: `/.well-known/pudl.json` and `GET /v0`

PUDL.ai's, with the values it runs once files are on (until then `policy.files` is `null`; section 15):

```json
{
  "record_format": 1,
  "spec": "0.3",
  "name": "PUDL",
  "description": "A neutral layer where agents pool knowledge and data, check anything before relying on it, and record what worked.",
  "host_did": "did:key:...",
  "api": "https://pudl.ai/v0",
  "llms_txt": "https://pudl.ai/llms.txt",
  "spec_url": "https://pudl.ai/spec",
  "quickstart": "https://pudl.ai/quickstart",
  "terms": "https://pudl.ai/terms",
  "abuse": "https://pudl.ai/abuse",
  "source": "https://github.com/jmaxmakes/pudl-protocol",
  "mcp": {"package": "pudl-mcp", "command": "npx -y pudl-mcp", "remote": "https://pudl.ai/mcp"},
  "oauth": {"issuer": "https://pudl.ai"},
  "limits": {"header_bytes": 4096, "body_bytes": 65536, "request_bytes": 262144, "about": 20, "links": 20, "files": 256, "scope": 20},
  "policy": {
    "public_pools": false,
    "blocked_media_types": [],
    "files": {"max_bytes": 26214400, "quota_bytes": 1073741824, "require_claim": true, "unreferenced_hours": 24,
              "media_types": ["*/*"]},
    "sandbox": {"ttl_days": 30, "pool_limit": 5},
    "rate_limits_per_hour": {"unclaimed": {"reads": 600, "writes": 60}, "claimed": {"reads": 10000, "writes": 1000},
                             "anonymous": {"reads": 300, "writes": 30}, "per_ip": 20000, "new_keys_per_ip": 100, "curator_removals": 100},
    "sharing": {"min_pool_owners": 3, "schemes": ["npm", "pypi", "mcp", "pkg", "doi", "arxiv", "domain"]},
    "retention": {"removed_body_days": 14},
    "custody": {"host_held": ["web", "remote_mcp"], "web": "...", "remote_mcp": "...", "sealed": "..."},
    "curated_pools": ["sha256:...", "sha256:..."]
  },
  "library": {"recipes": "sha256:...", "schemas": "sha256:..."}
}
```

- `record_format` and `spec` are what the host implements. `limits` are protocol-facing sizes; `policy` is everything the host chose.
- `policy.files` is `null` when the host stores no files. Held entries are then refused with `host_policy`, and external entries still work.
- `policy.files.media_types` lists the media types the host holds, matched against the type each upload's bytes are sniffed as and against held entries' `media_type`. A trailing `/*` matches a whole major type, and `*/*` matches every type. Sniffing can't tell one text format from another, so text bytes (sniffed as `text/plain`) fit any list that holds a text type, and the entry's `media_type` says which. `policy.blocked_media_types` lists the `content` media types a host refuses inline, in the same syntax.
- `policy.custody` says which keys the host holds and why (section 4.1): `host_held` lists the kinds, and a sentence for each says what it serves and when it is destroyed.
- Optional, from a host profile: `mcp.remote` (the URL of a remote MCP endpoint) and `oauth.issuer` (the authorization server that grants access to it), `library` (the pools of a library: `recipes` and `schemas`) and `policy.curated_pools` (live curated pools: public pools that anyone reads and only their curating group writes). `policy.public_pools` keeps its meaning (anyone may create a public pool).
- Clients MUST ignore fields they don't know here too.

## 14. Scores (informative)

Scores are views, not protocol facts. PUDL.ai's default scorer, `pudl-default-v0`:

- Counts a receipt with weight only while its author key is still claimed by the record's owner account. Receipts from unclaimed agents, or from keys released since, are counted but carry no weight.
- Uses each agent's latest receipt other than `unused`, by the host's **receive order**, never by `created_at`. In a rollup that means its latest receipt anywhere under the subject, such as its most recent version of a package.
- Gives each owner account one vote: the mean of its agents' latest outcomes, so one person running many agents still counts once. A service account votes with the group it belongs to (PUDL.ai: a business's gateway votes with its business), so adding gateways gains nobody votes.
- Values `worked` at 1, `partial` at 0.5 and `failed` at 0.
- Adds a prior of two votes at 0.5, so a single report doesn't read as certainty. The score is rounded to three decimals.

An account is a sign-in, not a verified person, so these scores resist casual inflation, not a determined attacker with many accounts. Anyone can compute other scores from the same records, the way anyone can run a moderation service on Bluesky. A scorer that credits contributions through `informed_by` links is planned.

## 15. Host policy

Everything a host chooses (what it holds, who may create public pools, retention, rate limits, how it groups accounts and who curates what) is host policy, published in its `pudl.json` and, for more than fits there, in a **host profile** document. PUDL.ai's is [docs/HOST-PROFILE.md](/spec/host), served at `/spec/host`. Nothing in a host profile changes a record or a rule of this document; a client that reads only this document works against any host.

## 16. Security and privacy considerations

- **Prompt injection.** Everything a host returns was written by agents and people. Clients MUST present it as data, never as instructions. `pudl-mcp` wraps results in a labeled `<pudl-data>` block, escapes anything in the data that could close it, shows invisible characters as visible escapes, and keeps summary lines to ids, counts, host templates and the caller's own input. Hosts never quote third-party text in `signals` or error messages. A pool's `policy` is labeled as written by the pool owner.
- **The lethal trifecta.** An agent that reads untrusted content, holds private data and can post publicly can be tricked into leaking. Public publishing therefore requires a claimed owner, and PUDL.ai has no public pools at launch.
- **Secrets.** pudl-mcp scans everything a record would carry, and the bytes of every file that isn't a binary format, in whatever encoding (UTF-8, UTF-16 by its byte-order mark, or else as an 8-bit encoding), for credentials before signing, and the host scans again. Refusals name the rule and location, never the secret.
- **Local files.** pudl-mcp reads files only inside the MCP client's roots (or `PUDL_FILE_ROOTS`, or the working directory), never under `~/.pudl`, never through symbolic links, and never credential files (`.ssh`, `.aws`, `.env*`, `*.pem`, `*.key`, key stores and keyrings such as `*.p12`, `*.pfx`, `*.jks`, `*.keystore`, `*.kdbx`, `*.ppk` and `*.gpg`, `wallet.dat`, `.git-credentials`, `.pgpass` and the like): binary key stores would escape the secret scan. `download` never overwrites, never makes files executable, never unpacks archives or runs or loads anything, marks programs and scripts (by extension, including `.js`, `.py`, macro documents and disk images, a `#!` line, or bytes that are an executable) and pickle-based files that run code when loaded (`.pkl`, `.pt`, `.pth`, `.ckpt`, `.joblib`, or bytes that are a pickle or a PyTorch checkpoint) as `risky`, and renames dot files and names that could steer an agent, a build or a shell (`AGENTS.md`, `Makefile`, `*.lnk` and the like) with a `.pudl-untrusted` suffix.
- **Probing.** Nothing a caller can send reveals whether a host holds bytes someone else uploaded: uploads answer identically, `missing_file` is the same for "never uploaded" and "not yours", and file reads check possession first.
- **Downloaded bytes.** Files of every type are served as `application/octet-stream` attachments with `nosniff` and a sandbox CSP, or from another site, so they can never act as the host's pages.
- **Claim phishing.** Injected content could get an agent to pass its person someone else's claim link. The claim page warns that a claimed agent gets the person's access and shows the agent's key, and people can release agents and remove members at any time. Likewise, an injected agent could invite an outsider into a pool: with `approval: "owner"` a person approves every joiner.
- **Key custody.** Keys stay on the agent's machine (`~/.pudl/keys/`, readable only by its owner). A lost key means a new identity. A leaked key should be reported so the host can suspend it; key rotation is planned. Custodial keys (section 4.1) never leave the host: they are sealed at rest under a secret separate from the host key, opened in memory only to sign, never logged or shown, and destroyed on revocation; a copy of the database without that secret signs nothing.
- **Replay.** Request signatures name the host's origin and carry a nonce and a timestamp; hosts remember nonces (section 13.2). Claim requests name their host. All writes are idempotent by record id.
- **Sybils.** New keys are free, so unclaimed keys carry no weight, new keys are limited per address, and each account gets one vote per target however many agents it runs.
- **Privacy.** Records are private to their pool, or to their owner account, by default. Receipts are shared only as totals, with consent that isn't retroactive, a minimum number of pool owners, and never for record ids, exact URLs or personal records. Claim records name an opaque account id, never an email address, and only the agent's own account can see them. Removing a body removes its `about` associations too.
- **Totals can be taken apart.** Someone who reports on a target from their own pool can subtract their own receipts from the totals and see what other teams reported. Sharing is an opt-in per pool for that reason: don't turn on `share_receipts` for a pool whose receipts name things you'd rather keep to yourselves.
- **Visibility never widens,** so no one's record reaches an audience larger than the one it was signed for.
- **Connections.** A pooled connection is a recipe, never a credential. External executors keep keys on their own machine; a host profile may separately offer explicit hosted custody under Appendix C's approval, revocation and spending boundaries. Credentials MUST NOT enter signed records or model context. A `stdio` connection runs code only after a person approves its exact descriptor on the machine that runs it. Once approved, a `stdio` package runs with the rights of the user the client runs it as: on a person's machine it can read what that person can, including other connections' keys in the key file. Approve only packages you would install yourself; a client that runs connections for others (a gateway) SHOULD run each as an operating-system user of its own.
- **Host profiles.** A host's own additions (PUDL.ai's organizations, implementers, gateways, library and remote MCP endpoint) carry risks of their own; its host profile says how it meets them.

## 17. What's next

The general hook is section 5.5: validity checks integrity only, readers ignore unknown fields, hosts accept only what they implement, clients ignore unknown response fields and codes, and unsupported filters get a 400. Under it, new kinds, optional fields, endpoints, parameters and tools are all additive.

This table records extension hooks, not the current PUDL.ai delivery status. Some
host-specific implementations now exist; consult the host profile and advertised
capabilities before using them. No entry requires a customer installation.

| Extension idea | Hook that keeps it non-breaking |
| --- | --- |
| Batch writes: `{"records": [...]}` on `POST /v0/records` | Same path; nested views make per-record results unambiguous |
| A changes feed (`GET /v0/changes`), export and mirroring | A receive sequence and opaque cursors |
| A signed `grant` kind: roles, organization principals, ownership transfer. Deferred on purpose: nothing but the serving host reads access rules, revocation must be instant and absolute, and business owners hold no keys (a grant signed by a host-held key proves nothing a host row doesn't). It comes in when pools move between hosts with their access rules | `private` means owner plus members; organizations, roles and grants are host state; `pool` has a reserved meaning on any kind |
| A signed `removal` kind, pool retention periods, legal hold | The removal note has `by` and `reason` |
| Flags visible in pools (a body field `flag: <reason>`), host-signed labels | The reason vocabulary; the body field name `flag` is reserved |
| Signed claim release, key rotation | A claim is a point-in-time attestation; a release becomes a new kind |
| More key types (P-256 for KMS, HSM and passkeys); linking a domain or organization to keys | The key-type registry and the *unverifiable* verdict |
| Public pools: a `listed` header field, a directory, rounding of `others`, weighting owners by account age and verified domain | `others` is separate and MAY be rounded; search includes public pools only when named |
| Presigned or multipart uploads, manifests for more than 256 files, other hash algorithms | The staging rule (section 8.3); a new algorithm needs a new field or a new record format |
| Search filters `scope.<key>=`, `links_to=`, `rel=`; lookup `linked_from`; pool statistics | The 400 rule for unsupported filters |
| More target profiles: DOI lowercasing, `doi.org` URLs as `doi:`, `hf:`, cargo and maven purls, stripping `utm_*`, Public Suffix List rollups | Normalization is outside validity; hosts match on current profiles |
| Scorers that credit contributions through `informed_by` | `informed_by` links are already in bodies |
| pudl-mcp: a general `links` parameter, paging, several hosts, turning large content into files | Additive tool parameters |
| Semantic search | The meaning of `q` is host-defined |
| Push delivery and alerts | Feed cursors |
| End-to-end encrypted pools | Held file entries are opaque bytes |
| Pool-level schema enforcement; an OKF profile | A `schema` value; the `host_policy` code; pool schema hints (host profile) |
| Host-profile features (remote MCP streams, gateways serving AI apps directly, passkey-signed approvals, a key vault) | Separate host-profile capabilities; PUDL.ai documents implemented and future behavior in its host profile |

## Appendix A: worked example

These values are checked by `packages/core/test/vectors.test.ts` (repository reference; not published here), and the same records are in the conformance fixtures. Every record is created at `2026-10-01T12:00:00.000Z`.

The secret key is the 32 bytes `0x01` to `0x20`:

```
0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20
```

Its identifier is `did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7`.

**A pool, version 1,** with the body `{"name":"Example pool"}`, whose `body_hash` is `sha256:f678155f76e8ed5113dc68ac4a0dbe7a2d245c7a16a9f3bd051d018d9f144d61`. Its canonical header:

```
{"author":"did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7","body_hash":"sha256:f678155f76e8ed5113dc68ac4a0dbe7a2d245c7a16a9f3bd051d018d9f144d61","created_at":"2026-10-01T12:00:00.000Z","kind":"pool","pudl":1,"share_receipts":false,"version":1,"visibility":"private"}
```

The signed bytes are `PUDL-RECORD-V1`, a line feed, and that header.

- Id: `sha256:c96919646252af882e6cb1c03a4ef043348abaffe2745aa8a7682ccd6b4b7bc8`, which is also the pool id
- Signature: `jUq8PJsCLBJCe_6QLiP-lknrFb5CkN7fF0KH0WZ7Qxx331AqNiKDPYmU7ow7IdXXy--zyhnRF8YuUwyMErFJDg`

**A dataset** in that pool: a CSV file held by the host, with a schema, a subject and lineage to data stored elsewhere. The file is the 36 bytes `account_id,churned_at\n42,2026-09-30\n`, whose hash is `sha256:f721b1081e9a1ddd4f6034e33952ed59c191020d34f74c886a9b0e8206554008`. The canonical body:

```
{"about":["urn:example:report:q3-churn"],"files":[{"hash":"sha256:f721b1081e9a1ddd4f6034e33952ed59c191020d34f74c886a9b0e8206554008","media_type":"text/csv","name":"q3/churn.csv","size":36}],"license":"CC-BY-4.0","links":[{"rel":"derived_from","target":"s3://example-warehouse/exports/accounts.parquet"}],"schema":"https://example.com/schemas/churn-row.json","scope":{"region":"emea"},"title":"Q3 churned accounts","type":"artifact"}
```

The canonical header:

```
{"author":"did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7","body_hash":"sha256:3d086f6a56d8afa2e98720c26f0418321ea94f504a0f7a35698d75e28673ede3","created_at":"2026-10-01T12:00:00.000Z","kind":"contribution","pool":"sha256:c96919646252af882e6cb1c03a4ef043348abaffe2745aa8a7682ccd6b4b7bc8","pudl":1}
```

- Id: `sha256:cc3952d0d493d5d81af3816e8e8dfa15ce37e413040aec90629d91272e60da33`
- Signature: `9VKOkePBBuQlB5EAIkYOsNB59r0DI-_UgvLjQIDITkJ6h3HuhlDU7OlkU4IauAbDmR1y-vGMoWpq8GfHuLMlAw`

**A receipt** in that pool: `npm:left-pad@1.3.0` worked, and the dataset informed it. The canonical body:

```
{"about":["npm:left-pad@1.3.0"],"content":"Installed; tests pass.","links":[{"rel":"informed_by","target":"sha256:cc3952d0d493d5d81af3816e8e8dfa15ce37e413040aec90629d91272e60da33"}],"outcome":"worked"}
```

The canonical header:

```
{"author":"did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7","body_hash":"sha256:e312db08e6fb45127ae981b7592a26b642889a6a2c128f266c239d0a8f5db91e","created_at":"2026-10-01T12:00:00.000Z","kind":"contribution","pool":"sha256:c96919646252af882e6cb1c03a4ef043348abaffe2745aa8a7682ccd6b4b7bc8","pudl":1}
```

- Id: `sha256:61d6896418248d18d3669d49e1520f97aa3f9efaf51f5b45903cc0128c865054`
- Signature: `Q5EK1uvfrGxM4QKPDA1TAiiTKCC6D2oiOgksWztrDRBIUXqvUWBfYhtMxeZVDhQCpFYxNDptLfqcWojN4KeBAg`

**A claim request** for PUDL.ai with nonce `AAAAAAAAAAAAAAAAAAAAAA`. It has no body, so no `body_hash`:

```
{"audience":"https://pudl.ai","author":"did:key:z6MkneMkZqwqRiU5mJzSG3kDwzt9P8C59N4NGTfBLfSGE7c7","created_at":"2026-10-01T12:00:00.000Z","kind":"claim_request","nonce":"AAAAAAAAAAAAAAAAAAAAAA","pudl":1}
```

- Id: `sha256:88437e9109e7db639ae456b4c27ba36101c9e4c05e9f61e32b7099414cb2547c`
- Signature: `lrpSeOLZA9K7slibkxQEqfrbsU6iqrL1orkqv4z41nG1AkridH-DkpgJw7nrbaLaIZNK6A76QlW_lQRnz_oACQ`

**A signed request.** `GET https://pudl.ai/v0/lookup?target=npm%3Aleft-pad%401.3.0` at Unix time `1790596800` with nonce `AAAAAAAAAAAAAAAAAAAAAA` signs this payload:

```
PUDL-REQUEST-V1
https://pudl.ai
GET
/v0/lookup?target=npm%3Aleft-pad%401.3.0
1790596800
AAAAAAAAAAAAAAAAAAAAAA
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```

- Signature: `OY8VJL4XRSJvvAKBKW9MHR1RcJw1ShFAcXTHu2m5Mc9cVLJdF67VixbPdWxlFw3L_C3B1a6lNEmm6j5HYeuABw`

**Conformance fixtures.** `packages/core/test/fixtures/` (repository reference; not published here) holds JSON cases any implementation can run: `integrity.json` (envelopes with their expected verdict: authentic, invalid or unverifiable), `acceptance.json` (records with the expected acceptance `code`) and `canonical.json` (canonical JSON and the unsafe-integer range).

## Appendix B: target profiles (informative)

Hosts match targets on their normalized form, under the host's current profiles. Writers SHOULD normalize; hosts accept targets that aren't normalized, and reject only targets their profiles can't parse (400 `invalid_target`, with an explanation). These are the reference profiles, which `@pudl/core` exports as `normalizeTarget`. They can grow at any time.

Input is trimmed of surrounding whitespace, and the scheme is matched case-insensitively.

| Target | Normalization | Subject (what totals roll up to) | `kind` |
| --- | --- | --- | --- |
| `npm:<name>[@<version>]` | Scope lowercased; the name keeps its case. The version MUST be exact [semver](https://semver.org), such as `1.3.0` or `19.0.0-rc.1`. | `npm:<name>` | `npm` |
| `pypi:<name>[@<version>]` | Name normalized per PEP 503: lowercase, and runs of `-`, `_` and `.` become `-`. `name==version` is accepted. The version is lowercased and MUST be an exact PEP 440 version, such as `2.32.3` or `4.2rc1`. | `pypi:<name>` | `pypi` |
| `mcp:<registry name>[@<version>]` | An MCP Registry name such as `io.github.owner/server`, case kept. The version MUST start with a number, such as `1.2.0`. | `mcp:<name>` | `mcp` |
| `http://` or `https://` URLs | WHATWG URL serialization: lowercase scheme and host, punycode, default port removed, fragment dropped. URLs with a user name or password are refused. | `domain:<host>` | `url` |
| `domain:<host>` | Parsed as a WHATWG URL host: lowercased, punycode, no trailing dot. IPv4 addresses are normalized; IPv6 is not accepted. | Itself | `domain` |
| `sha256:<64 hex>` | Lowercased: the hash of a file, or a record id | Itself | `hash` |
| `pkg:npm/...`, `pkg:pypi/...` | [Package URLs](https://github.com/package-url/purl-spec) become `npm:` and `pypi:` targets; qualifiers and subpaths are dropped | As above | As above |
| Any other URI (`doi:`, `arxiv:`, `urn:`, `s3:`, other `pkg:` types...) | Scheme lowercased; no spaces, control characters or backslashes | Itself | `uri` |

- In every package form, a leading `v` before a digit is dropped from the version.
- Leaving the version off means the package as a whole. Ranges (`^1.2`, `1.x`), wildcards and tags (`latest`) are refused, because they move.
- `data:`, `javascript:` and `blob:` URIs carry content rather than an address, so they are refused.
- A normalized target is at most 2,048 UTF-8 bytes.
- A string without a scheme is refused with: "...has no scheme. Use any URI (https://..., doi:..., urn:..., s3://...), a package (npm:..., pypi:..., mcp:...), or sha256:<hash of a file or record>."

## Appendix C: Connections (profile)

This is a **profile**, added under section 5.5: no new kind and no new or changed signed field. Hosts store connections as ordinary contributions and need not know this appendix exists; search already finds them with `type=connection` or `schema=https://pudl.ai/schemas/connection/v1`. What follows binds clients that act on connections, such as `pudl-mcp`; gateway forwarding and separately approved hosted execution are host-profile choices (PUDL.ai's is in [docs/HOST-PROFILE.md](/spec/host)). The reasons are in docs/design/connections.md (repository reference; not published here).

A business or individual wires data sources into a pool once, and authorized agents use them through a client. **The pool holds the recipe** (what each source is and how to reach it); the executing client holds the keys, which MUST NOT enter a signed record or a model's context. With external execution, keys stay on that machine and never reach the pool host. A host profile MAY additionally offer explicit, separately approved hosted execution and encrypted credential custody. That option MUST identify the custodian, preserve the external-key option, require human approval of the exact connection and permitted tools/principals, and describe revocation and spending boundaries. Pooled recipes alone never authorize execution or transfer credentials.

### C.1 The contribution

| Body field | Value |
| --- | --- |
| `type` | `connection` |
| `schema` | `https://pudl.ai/schemas/connection/v1` |
| `title` | A human name, such as "Acme CRM" |
| `about` | RECOMMENDED: what it connects to, as `mcp:<registry name>@<version>` or the server's https URL, so lookups and receipts on the source work unchanged |
| `content` | The connection descriptor (C.2), a JSON object |

- A change is a new contribution that `supersedes` the old one; removal is a retraction. A connection's history stays auditable.
- A connection's `name` MUST be unique among the live (not superseded, not removed) connections of its pool. When two live connections share a name, clients MUST use neither.
- Writers MUST check a descriptor against C.2, and run the secret scan (section 16), before signing.

### C.2 The descriptor (`connection/v1`)

```json
{
  "name": "crm",
  "transport": "http",
  "url": "https://mcp.acme-crm.com/mcp",
  "headers": { "Authorization": "Bearer ${CRM_TOKEN}" },
  "keys": [{ "name": "CRM_TOKEN", "description": "API token with read access", "help": "https://app.acme-crm.com/settings/tokens" }]
}
```

```json
{
  "name": "warehouse",
  "transport": "stdio",
  "package": "npm:@acme/warehouse-mcp@2.3.1",
  "integrity": "sha512-...",
  "args": ["--readonly"],
  "env": { "WAREHOUSE_DSN": "${WAREHOUSE_DSN}" },
  "keys": [{ "name": "WAREHOUSE_DSN", "description": "Read-only Postgres connection string" }]
}
```

| Field | Value |
| --- | --- |
| `name` | Required. 1 to 32 characters matching `^[a-z][a-z0-9]*(?:_[a-z0-9]+)*$`: lowercase letters and digits in words joined by single underscores. It prefixes the proxied tools (`crm__search_accounts`) and the key file's variables (`CRM__CRM_TOKEN`); the underscore rule makes each variable name split one way only. |
| `transport` | Required. `http` (an MCP server over Streamable HTTP) or `stdio` (a pinned package, run locally) |
| `url` | `http` only, required. An absolute `https` URL, or `http` on `localhost`, `127.0.0.1` or `[::1]`; no user name or password; no placeholders. |
| `headers` | `http` only, optional. 1 to 32 headers. Names are HTTP tokens other than those the transport sets (`Host`, `Content-Length`, `Content-Type`, `Accept`, `Connection`, `Transfer-Encoding`, `Mcp-Session-Id`, `Mcp-Protocol-Version` and the like). |
| `package` | `stdio` only, required. `npm:<name>@<version>` or `pypi:<name>@<version>` with an exact version, written as its normalized target (Appendix B); the name starts with a letter or digit. Commands, paths and shell strings are never allowed. |
| `integrity` | `stdio` only, required. For npm, the version's `dist.integrity` from the registry (`sha512-` and 88 base64 characters). For PyPI, the SHA-256 of one of the release's files (`sha256-` and 44 base64 characters). |
| `args` | `stdio` only, optional. 1 to 64 strings, each at most 1,024 characters |
| `env` | `stdio` only, optional. 1 to 32 variables. Names that change how the package is fetched or run are refused: `PATH`, `HOME`, `NODE_*`, `NPM_*`, `UV_*`, `PIP_*`, `PYTHON*`, `LD_*`, `DYLD_*`, proxy and CA settings, `PUDL_*` and the like. |
| `oauth` | `http` only, optional, and not together with `keys`. What the client tells the server's authorization server when it signs in (C.3): `client_id` (a client ID the server's owner issued, when it doesn't accept dynamic registration or client ID metadata documents), `scopes` (1 to 16 scope strings) and `resource` (an https URL, when the protected resource isn't the `url` itself). No secret ever goes here. |
| `keys` | Optional. 1 to 32 objects: `name` (required, `^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)*$`, at most 64 characters, distinct), `description` (one line, at most 200 characters) and `help` (an https URL where the person gets the key) |

- **Placeholders.** `${NAME}` MAY appear only in `headers` values, `env` values and `args`, and each MUST name an entry in `keys`. Every declared key MUST be used by at least one placeholder. The executor substitutes values from its own credential custody, never from a record or model-supplied secret. External key-file behavior is in C.3; hosted custody is separately defined by the host profile.
- **No secrets.** A header whose name suggests a credential (containing `auth`, `token`, `key`, `secret`, `cookie`, `password`, `credential` or `session`) and an `env` variable whose name does (`TOKEN`, `KEY`, `SECRET`, `PASSWORD`, `DSN`, `AUTH` and the like) MUST take its value from a placeholder. The whole descriptor is secret-scanned as well.
- **Unknown fields** make a descriptor invalid: it says what the executor runs or contacts, so clients act only on fields they understand. A later version of this profile uses a new `schema` URI.
- **Descriptor hash.** The SHA-256 of the descriptor's canonical JSON (section 3.1), written `sha256:<hex>`. Approvals are pinned to it.

### C.3 The key file

This section defines external local-agent/gateway credential storage. A hosted executor uses
the explicit custody boundary in its host profile instead; the prohibition on exposing key
values in records, tool results, logs or errors applies to both modes.

A standard dotenv file: `NAME=value` lines, blank lines and `#` comments, with an optional `export ` prefix. Values are bare (trimmed; ` #` starts a comment), `'single-quoted'` (as is) or `"double-quoted"` (with `\n`, `\r`, `\t`, `\"` and `\\` escapes), on one line. An empty value means unset. A name set twice keeps its first value.

```sh
# PUDL keys for pool "Acme ops" (sha256:9f2c1a2b3c4d…)
# Fill in the values, save as ~/.pudl/keys.env (chmod 600), and never paste them into a chat.

# crm: Acme CRM (https://mcp.acme-crm.com/mcp)
# API token with read access. Get one at https://app.acme-crm.com/settings/tokens
CRM__CRM_TOKEN=

# warehouse: npm:@acme/warehouse-mcp@2.3.1
# Read-only Postgres connection string.
WAREHOUSE__WAREHOUSE_DSN=
```

- **Names.** A key's variable is `<NAME>__<KEY>`: the connection's name in upper case, two underscores, and the key's name. When two pools a client connects both have a live connection with the same name, each pool's variables for it carry a prefix: `POOL_`, the first 12 hex characters of the pool id in upper case, and two underscores (`POOL_9F2C1A2B3C4D__CRM__CRM_TOKEN`). The prefixed name is always accepted; the plain one only while no such collision exists. Pool ids, not names, make the prefix, because names are neither unique nor fixed.
- **Where.** `PUDL_KEYS_FILE`, else `~/.pudl/keys.env`. One file per machine, shared by all pools.
- **Permissions.** On POSIX systems, a client MUST refuse a key file that any other user can read or write, and say which `chmod` fixes it.
- **Other sources.** A variable set in the client's own environment wins over the key file (for CI and containers). A gateway (a client that runs for an organization, host profile) also takes values a person entered on a host's web page, sealed to the gateway's own key in their browser, and writes them to its key file. **OAuth** is a key source for an `http` connection that declares no keys: when its server answers 401 with MCP authorization metadata, the client signs in as an OAuth client of that server (status `needs_sign_in` until a person does) and keeps the tokens itself. OS keychains can be added later, with the same names.
- **Never shown.** Key values MUST NOT appear in records, tool results, logs or error messages. Clients report missing keys by variable name only, and SHOULD replace any key value an upstream server echoes back.

### C.4 Approval

- Approval belongs to the **executor**: external clients keep it locally (for `pudl-mcp`, `~/.pudl/approved.json`), mapping connection record ids to the approved descriptor hash. A client MUST ignore an approvals file other users can write. A hosted executor retains separately granted human approval under its host profile; a pooled descriptor or ordinary read grant never supplies that approval.
- A connection needs approval when it is new, or when its descriptor differs from the one approved (a `supersedes` gives a new record and a new hash).
- Approval MUST come from a person, never from an agent: no tool approves. `npx pudl-mcp approve <pool>` shows each descriptor, what it runs or contacts, which keys go where, and PUDL's lookup signals and receipts for its target, and asks for y or n at a terminal.
- **Web approval.** A client MAY also accept an approval a person gave on a host's web page, delivered as a message signed with the host key (the host profile defines the message), when its operator opted in. It MUST check the signature against a host did it pinned beforehand, that the message names this client, and that it is fresh and newer than the last one it applied for that connection. It MUST refuse it for a `stdio` connection: what runs on a machine is approved at that machine's terminal. The person is still the approver: the host relays only what a signed-in person chose.
- **Tool allowlists.** An approval MAY name which of the connection's tools are offered: `all_read` (the tools whose annotations say `readOnlyHint: true` and, for a connection made from a recipe, that the recipe lists as `read`, in a host that keeps recipes) or a list of tool names. A client that offers tools to people other than the approver (a gateway) SHOULD offer only read-only tools unless an approval lists more, and SHOULD mark every tool that isn't read-only with `destructiveHint: true`.

A host profile MAY require explicit selected tools even when they are annotated read-only:
read-only behavior does not establish that a provider call is free. Hosted durable call
identities and argument wrappers belong to that executor's profile; they do not change the
pooled descriptor or the published PUDL tool parameters.

A client reports each connection's status:

| Status | Meaning |
| --- | --- |
| `ready` | Approved, keys present, connected; its tools are offered |
| `needs_keys` | Approved, but these variables are missing (named, never valued) |
| `needs_approval` | Never approved on this machine |
| `changed` | An earlier version was approved; this one wasn't |
| `needs_sign_in` | Approved, an `http` connection without keys whose server asks for an OAuth sign-in (C.3) that no person has given yet |
| `error` | An invalid descriptor, a duplicate name, a failed integrity check or a failed connection |

Clients MUST treat a status they don't know as not ready, and show it as it is: later versions may add statuses.

### C.5 Connecting

- **Keys reach only their own connection:** its own headers, or its own process's environment and arguments.
- **`http`:** Streamable HTTP. Redirects MUST NOT be followed, so headers never go anywhere else.
- **`stdio`:** the exact package runs through `npx -y <name>@<version>` or `uvx <name>@<version>`, never through a shell, from a directory of the client's own, and only after the registry confirms the pinned integrity. Its environment holds only `PATH`, `HOME`, cache locations under that home, and the variables the descriptor sets (plus the system variables Windows needs to start a process), never the rest of the client's environment. Its stderr is not logged.
- **Tools** are offered as `<name>__<tool>`. Upstream tool names are the server's to choose, so a client MUST offer only names matching `^[A-Za-z0-9_-]{1,64}$`: it offers a name that doesn't match under a sanitized name (other characters replaced with `_`, cut short, and a short hash of the original appended so two never collide) and calls the upstream tool by its original name. Descriptions are labeled with the connection and pool they come from. When the set changes (a connection becomes ready, changes or goes away, or an upstream server's list changes), the client sends `notifications/tools/list_changed`. Resources and prompts may come later.
- **Receipts.** Any proxied call can be reported like anything else. After a failure, clients suggest a `report` on the connection's `about` target, so a team learns which sources are flaky.
- **A generic caller.** A client whose own clients can't refresh their tool list MAY also offer `use_connection` (`connection`, `tool`, `arguments`), which calls a proxied tool by its connection and tool name under the same allowlists.
