mentionable.dev

PolicyPart — Mentionable v0.1

Status: Draft v0.1 — first published revision. Last updated: 2026-05-01 Editors: JC Kim, Claude

This is a wire specification for a new Part kind in the Mentionable normalized message: PolicyPart. It defines a transport-agnostic discriminated union for refusing requests on policy grounds (consent required, unauthorized, payment required, forbidden, rate-limited, legally blocked, temporarily unavailable).

This document specifies only what crosses the wire. Per-platform UX behavior — how a particular adapter renders a button, where it stores callback state, how it routes resumption back to a chat thread — lives in companion implementation design documents under docs/design/. The first such design doc is docs/design/slack-policy-resumption.md, which describes our Slack reference transport module’s intended behavior.

PolicyPart is the interactive step-up layer. Already-verified ambient identity (Email DKIM/DMARC, ActivityPub signatures, A2A tokens, Slack workspace identity, OAuth, SIWE, agent self-signatures) is surfaced on NormalizedMessage.sender.identities by identity-evidence-v0.1.md. Agents SHOULD inspect that evidence first and emit unauthorized or consent_required only when the available evidence is insufficient for the requested operation.

The historical drafts that explored a wider surface (delegation primitive, profile tiers, AP2 embedded flow, JWT mandates) are archived under docs/proposals/archive/ along with the multi-reviewer audit summary that led to the current shape.


1. Conformance terms

The key words MUST, MUST NOT, REQUIRED, SHALL, SHALL NOT, SHOULD, SHOULD NOT, RECOMMENDED, MAY, and OPTIONAL are interpreted as described in BCP 14 (RFC 2119, RFC 8174) when, and only when, they appear in all capitals.

Implementations that conform to this spec MUST advertise the extension URI https://mentionable.dev/ns/policy/v0.1 in their agent card’s A2ACapabilities.extensions array (see §5.1 and agent-card.md). There is no tier system. Either an agent implements all seven kinds in §3 and the wire mappings in §4, or it does not declare conformance.

Deprecated alias. During the transition window (see ../wiki/url-scheme.md and issue #503) the legacy URI https://mentionable.dev/spec/policy/v0.1 is recognised as an alias on inbound only. Outbound emit MUST use the canonical /ns/ URI.


2. Problem

The current NormalizedResponse collapses every non-success into a JSON-RPC error or an empty text reply. Real agents need to say:

Encoding these as JSON-RPC errors is leaky: ActivityPub and Email do not carry JSON-RPC, and even within A2A, downstream adapters need structured access to the refusal kind, the actionable URL, and any payment quote or auth challenge.

The agentic-web ecosystem has begun converging on payment refusals (x402 makes 402 Payment Required load-bearing; AP2 carries payment policy as in-band typed data parts; OAuth/OIDC has consent_required deployed across many IdPs). v0.1 normalizes these into a single PolicyPart discriminant whose names track HTTP status codes where they exist, and the most-deployed adjacent protocol token where they don’t.


3. PolicyPart shape

3.1 Naming rule

Each kind value is chosen by this priority, in order:

  1. The exact name of the closest HTTP status code phrase, lowercased and snake_case’d (payment_required, unauthorized, forbidden, too_many_requests, unavailable_for_legal_reasons, service_unavailable).
  2. If no HTTP status fits cleanly, the well-known token from the most-deployed adjacent protocol (e.g. OIDC’s consent_required).
  3. Inventing a new name only when neither (1) nor (2) fits. This has not been used in v0.1.

The mapping for each kind:

kindSource
consent_requiredOIDC error tokenrule (2)
unauthorizedHTTP 401rule (1)
payment_requiredHTTP 402rule (1)
forbiddenHTTP 403rule (1)
too_many_requestsHTTP 429rule (1)
unavailable_for_legal_reasonsHTTP 451rule (1)
service_unavailableHTTP 503rule (1)

3.2 Common base

Every PolicyPart carries this base regardless of kind:

type PolicyPartBase = {
  /** Discriminant. */
  kind: PolicyKind

  /**
   * Machine-readable token identifying the specific reason within
   * the kind. Free-form string; namespaced when the value comes
   * from a known vocabulary (e.g. `oauth:consent_required`,
   * `mentionable:url_origin_mismatch`). Verifiers that don't
   * recognize the namespace MUST treat it as opaque and rely on
   * `kind` + `message` for routing.
   */
  code?: string

  /** Short summary. */
  title?: string

  /**
   * Human-readable detail to display to the end user. REQUIRED.
   */
  message: string

  /**
   * Optional translations of `title` and `message` keyed by BCP 47
   * language tag. When present, renderers select the entry whose
   * tag best matches the user's locale; fall back to `message`
   * and `title` if no match.
   */
  message_translations?: Record<string, { title?: string; message: string }>

  /**
   * Actionable URL — consent page, payment page, auth challenge,
   * blocked-by authority. Strongly RECOMMENDED: its absence means
   * the user cannot recover from the refusal in-band.
   *
   * **Origin binding (MUST).** The URL's host MUST exactly equal
   * the agent's *canonical host*, defined as the host portion of
   * the `links[rel=self]` href in the agent's WebFinger record
   * that points at the message endpoint that delivered this part.
   * (For agents discovered without WebFinger — e.g. private
   * deployments — the canonical host is the host of the agent
   * card's `url` field.) eTLD+1 matching is NOT permitted:
   * subdomain takeover and PSL-snapshot drift are real risks and
   * exact-host is the safe default. Validators MUST reject parts
   * whose `url` violates this as malformed.
   *
   * **Host comparison procedure (MUST).** Both sides are
   * normalized before comparison:
   *
   *   1. Apply RFC 3986 §6.2.2.1 case normalization (host
   *      lowercase) and RFC 5895 IDN-to-ASCII (punycode).
   *   2. Strip trailing dot.
   *   3. Strip default port (absent ≡ `:443` for `https`,
   *      `:80` for `http`). Only `https` is permitted for `url`
   *      in v0.1; `http` URLs are rejected as malformed.
   *   4. Reject any URL containing a `userinfo` component.
   *   5. For IPv6 literals, normalize per RFC 5952 before bracket
   *      comparison.
   *
   * **WebFinger transport (MUST).** WebFinger discovery MUST be
   * over HTTPS per RFC 7033 §4. Records served over plain HTTP
   * MUST NOT be used to bind any `url` origin.
   *
   * **Residual risks** (operator concern, out of wire scope).
   * Exact-host binding does not defend against attacker-controlled
   * confusable hostnames (IDN homographs registered end-to-end) or
   * dangling-DNS subdomain takeover of the agent's actual host.
   * Renderers MAY apply UTS #39 confusable detection on the
   * displayed agent address; operators SHOULD monitor DNS and CAA
   * for the canonical host.
   */
  url?: string

  /**
   * Accessibility-oriented label for the action. Renderers that
   * present `url` as a button SHOULD set the button's accessible
   * name to this value; if absent, fall back to a kind-specific
   * default ("Continue", "Pay now", "Sign in").
   */
  action_label?: string

  /**
   * Protocol-native escape hatch. Carries data the normalized
   * shape doesn't model.
   *
   * **Namespace requirement (MUST).** Every key MUST use a
   * reverse-DNS or registered prefix (`oauth.*`, `oidc.*`,
   * `mentionable.*`, `x402.*`, `<reverse-dns>.*`). Unprefixed keys
   * are RESERVED and MUST be ignored by verifiers.
   *
   * **Prototype-pollution defense (MUST).** Implementations that
   * read `data` MUST construct the parsed object via
   * `Object.create(null)` or strip `__proto__`, `constructor`,
   * and `prototype` keys before use. Implementations MUST NOT
   * deep-merge `data` into runtime objects.
   */
  data?: Record<string, unknown>
}

type PolicyKind =
  | 'consent_required'
  | 'unauthorized'
  | 'payment_required'
  | 'forbidden'
  | 'too_many_requests'
  | 'unavailable_for_legal_reasons'
  | 'service_unavailable'

3.3 Kind-specific extensions

Three of the seven kinds carry MUST/REQUIRED structured fields beyond the base.

3.3.1 unauthorized

type UnauthorizedPart = PolicyPartBase & {
  kind: 'unauthorized'

  /**
   * Auth challenges in priority order. REQUIRED to contain ≥1
   * entry per RFC 9110 §11.6.1. HTTP-binding adapters MUST
   * reconstruct `WWW-Authenticate` from this list.
   *
   * `params` carries auth-param values per RFC 9110 §11.5.
   * Values are stored unquoted in the normalized form; the
   * HTTP-binding layer applies RFC 9110 §5.6 quoted-string
   * encoding when emitting the header. The `token68` form is not
   * separately modeled.
   *
   * Header-injection defense (MUST). Both encoders AND decoders
   * MUST validate that values contain no CR, LF, NUL, or any
   * byte outside RFC 9110 §5.6 `token` / `quoted-string` charset.
   * Validation on the encoder side alone is insufficient;
   * non-conformant emitters can otherwise reach downstream HTTP
   * clients via passive relays.
   *
   * For Bearer challenges (RFC 6750), `params.error` is the
   * canonical home for OAuth error tokens (`invalid_token`,
   * `insufficient_scope`, etc.). When `code` is also set on the
   * containing PolicyPart, the two MUST NOT contradict; the
   * convention is `code: 'oauth:<error-token>'`.
   */
  auth_challenges: Array<{
    scheme: string // e.g. 'Bearer', 'Basic'
    params?: Record<string, string> // RFC 9110 auth-param values, unquoted
  }>
}

3.3.2 too_many_requests

type TooManyRequestsPart = PolicyPartBase & {
  kind: 'too_many_requests'

  /**
   * Delta-seconds form of HTTP `Retry-After`. HTTP adapters MUST
   * set `Retry-After: <retry_after_seconds>`. Renderers SHOULD
   * present this in the user's locale timezone; the field itself
   * stays in seconds-from-now.
   */
  retry_after_seconds?: number
}

3.3.3 payment_required

type PaymentRequiredPart = PolicyPartBase & {
  kind: 'payment_required'

  /**
   * Open list of payment options the agent will accept, in
   * preference order. REQUIRED to contain ≥1 entry.
   *
   * `scheme` is a reverse-DNS namespace identifier announcing
   * which payment protocol's wire format the `payload` carries.
   * v0.1 does not bind the spec to any specific payment ecosystem.
   *
   * **`payload` is opaque to this spec.** Implementations that
   * recognize a known `scheme` MAY parse the `payload` to drive
   * scheme-specific behavior; implementations that don't MUST
   * pass it through unchanged. Schema, encoding, signing, and
   * compatibility of the `payload` are all the responsibility of
   * the named scheme's spec — not this spec.
   *
   * Common scheme prefixes (informative):
   *   - `x402.*`   — x402 payment schemes
   *   - `ap2.*`    — AP2 protocol mandates
   *   - `stripe.*` — Stripe payment intents
   *   - `ln.*`     — Lightning Network invoices
   *
   * **Prototype-pollution defense (MUST).** The same defense
   * required for `data` (§3.2) MUST be applied when parsing
   * `payload`.
   */
  accepted_payments: Array<{
    scheme: string
    payload: Record<string, unknown>
  }>
}

Note on x402.* payload shape (informative). When scheme starts with x402., the payload is conventionally an X402PaymentRequiredResponse envelope — { x402Version, accepts: [ PaymentRequirements... ] } — as defined in a2a-x402 v0.2 §“Wire shape” and inheriting x402 v1’s PaymentRequirements record. The first non-a2x reference implementation of this shape ships in @mentionable/x402-bridge and is exercised in examples/llm-agent-vercel. This is informative — other schemes (ap2.*, stripe.*, ln.*) carry their own scheme-defined payload shapes and do not inherit the x402 envelope.

Note on payload integrity. v0.1 does NOT define an agent-side signature on accepted_payments[].payload. Transports that do not authenticate the agent’s identity at the channel layer introduce MITM risk on fields like a payment recipient address. Where this matters, deployments MUST either (a) restrict to authenticated transports for payment_required parts, or (b) rely on scheme-native signing inside payload (e.g. AP2-signed mandates, x402’s extra field). A normalized signature field is future work; see §6.

type ConsentRequiredPart = PolicyPartBase & {
  kind: 'consent_required'

  /**
   * Opaque correlation token issued by the agent. The agent
   * receives this echoed when consent completes, allowing it (or
   * an intermediating adapter) to resume the original
   * interaction.
   *
   * REQUIRED. `state` MUST be ≥128 bits of entropy from a CSPRNG.
   * `state` MUST NOT be reused across recipients or across calls.
   * Agents SHOULD bind `state` server-side to the recipient
   * address and the originating correlation context (e.g. by
   * persisting a row keyed on `state` carrying
   * `(recipient, thread_id, issued_at, exp)` and verifying a
   * matching row exists at completion time; or by signing
   * `(recipient, thread_id, issued_at)` with the agent's
   * Ed25519 signing key — see [agent-card.md §5](agent-card.md))
   * and SHOULD reject completions whose binding does not match.
   *
   * `state` is a bearer token; treat it as such. Adapters that
   * route consent through their own infrastructure (see §4.5)
   * MUST avoid logging `state` in plain-text request logs and
   * MUST treat any leaked `state` as compromised.
   *
   * **OIDC interop note.** When the agent's consent page hands
   * off to an OAuth/OIDC IdP, the IdP's redirect_uri carries its
   * own `state` parameter. Intermediaries forwarding to an OIDC
   * IdP MUST namespace this field's value under a separate query
   * parameter name (`mentionable_state` is conventional) on the
   * IdP-bound URL, since OIDC owns the `state` query name and
   * may not preserve unknown extras through its signed
   * assertion.
   */
  state: string

  /**
   * URL the user-agent SHOULD navigate to once consent completes,
   * with `state` echoed as a query parameter. The agent's consent
   * page MUST honor the eventual return navigation. Origin
   * binding rules (§3.2 `url`) apply equally here.
   *
   * REQUIRED.
   */
  return_to: string
}

3.4 The full union

type PolicyPart =
  | ConsentRequiredPart
  | UnauthorizedPart
  | PaymentRequiredPart
  | (PolicyPartBase & { kind: 'forbidden' })
  | TooManyRequestsPart
  | (PolicyPartBase & { kind: 'unavailable_for_legal_reasons' })
  | (PolicyPartBase & {
      kind: 'service_unavailable'
      retry_after_seconds?: number
    })

3.5 Streaming behavior

A PolicyPart MAY appear in a streaming response. When it does:

A streaming validator MUST reject mid-stream PolicyParts. Receivers MUST treat any frame containing a PolicyPart as terminating the stream and MUST discard subsequent frames. A stream that closes without a terminal PolicyPart MUST be reported to the caller as a failed task (task.status.state: 'failed' on A2A; equivalent on other transports), not as success-with-empty.

Streaming integrity rests on the same transport channel-layer authentication described in §4.1 envelope integrity. An on-path attacker who can rewrite frames can substitute the terminal PolicyPart; v0.1 does not define an in-frame signature. Implementations carrying high-stakes PolicyParts (those with accepted_payments[]) over relayed transports SHOULD wait for F-2 before relying on the terminal frame’s content.

3.6 Conformance edge cases

SituationRequired behavior
kind: 'unauthorized' with empty/missing auth_challengesValidator rejects as malformed.
kind: 'too_many_requests' with no retry_after_secondsValid; HTTP adapter omits header; user-facing message says “try again later”.
kind: 'payment_required' with empty/missing accepted_paymentsValidator rejects as malformed.
kind: 'consent_required' without state or return_toValidator rejects as malformed.
url host ≠ agent’s canonical host (per §3.2)Validator rejects the part as malformed. Renderer behavior on a malformed part is informative — see per-platform design docs.
Unknown kind (forward compatibility)Pass through preserved; receivers MUST NOT infer success. Default rendering is implementer-defined; a neutral phrasing such as “this agent declined for an unrecognized policy reason” is suggested. The kind field on the wire is treated as an open string for forward-compat (the seven values defined here are the v0.1 subset).
data or payload contains __proto__ / constructor / prototypeImplementation strips and continues.
data or payload contains unprefixed keysVerifier ignores them; emitter is non-conformant.
Extension URI declared but emitted PolicyPart fails validationReceiver SHOULD demote the agent’s policy claim for subsequent messages in the same correlation context (e.g. same A2A task chain, same email thread). The demotion expires when the agent card is next refreshed or after one hour, whichever is sooner. Receivers SHOULD log the demotion. Receivers MUST reject SHOULD-bound completions whose state.issued_at is older than the demotion window, treating them as expired.

3.7 Canonical serialization

When a PolicyPart is hashed, signed, or echoed inside another signed container, implementations MUST apply canonical JSON serialization per RFC 8785 (JSON Canonicalization Scheme). For clarity, the load-bearing rules:

v0.1 has no normative surface that itself requires hashing. Defining this rule now lets implementations align their stable-stringify helpers before they are load-bearing for future work F-2 (agent-side payment_required signature).


4. Wire mappings

This section defines the wire envelope used on each transport. UI rendering — what a chat client shows the user, where buttons appear, how callback flows back to a user’s surface — is not in this spec. Per-platform rendering and resumption design lives in the companion design docs under docs/design/.

4.1 The mentionable.policy.* namespace (transport metadata)

On any transport whose normalized message carries an extensible metadata object, every PolicyPart is encoded as a nested object under the mentionable key (matching the existing metadata.mentionable.history precedent):

"metadata": {
  "mentionable": {
    "policy": {
      "v": "v0.1",
      "part": { /* full PolicyPart JSON per §3 */ }
    }
  }
}

The kind discriminant is read from part.kind. The envelope carries only v (envelope version) and part (the canonical PolicyPart) in v0.1.

Forward-compatible namespace reservation. Verifiers MUST ignore unknown keys under metadata.mentionable.policy.*. Future revisions MAY add sibling keys.

Envelope version skew (MUST). Verifiers receiving an envelope whose v they do not support MUST treat the part as an unknown kind: preserve metadata.mentionable.policy byte-for-byte for upstream propagation, do NOT infer success, do not surface a synthetic refusal, and emit task.status.state: 'failed' (when on A2A). Operational logging is implementation-defined.

Envelope integrity is transport-anchored. v0.1 does not define an in-envelope signature. The envelope’s authenticity rests on the transport’s channel-layer authentication: TLS to the agent’s WebFinger-bound host (A2A), DKIM-aligned From: (Email), or HTTP-Signature-authenticated delivery (ActivityPub when normative in F-4). On transports that do not authenticate the agent at the channel layer, receivers MUST NOT honor the envelope. A normalized in-envelope signature is future work; see §6 F-2.

Informative — recommended pattern for Connector-mediated resumption envelopes. Connectors that mediate consent_required / payment_required resumption (e.g. the Slack Connector under docs/design/slack-policy-resumption.md) receive a redirect from the agent’s verifier on the user’s behalf and re-deliver the resumption back to the agent over a separate transport. The redirect-leg trust gate is OUT of this spec’s scope, but implementations are encouraged to use the agent’s Ed25519 keypair (advertised on the agent card under mentionable.signing_key.pem, where alg is Ed25519) to sign the redirect’s canonical input bytes, and the consuming Connector should resolve the agent card via WebFinger and verify with the public half. This avoids the operational pain of a shared HMAC secret per (agent, Connector) pair and aligns with the Connector Card discipline described in §F-2 — both sides advertise their signing keys via discoverable well-known endpoints, no out-of-band coordination required.

4.2 A2A wire mapping

For agent-to-agent calls over the A2A JSON-RPC transport, PolicyPart is carried at:

task.status.message.metadata.mentionable.policy

The accompanying task.status.state value MUST be:

kindA2A state
consent_required'input-required'
payment_required'input-required'
unauthorized'auth-required'
forbidden'rejected'
too_many_requests'failed'
unavailable_for_legal_reasons'rejected'
service_unavailable'failed'

These choices follow A2A v1.0’s distinction between 'auth-required' (credential UX), 'input-required' (any other required user input), 'rejected' (terminal refusal that is not a runtime fault), and 'failed' (runtime fault).

State-kind alignment (MUST). Receivers MUST reject any A2A frame whose task.status.state does not match the kind per the table. Absence of a PolicyPart in a frame whose state is 'auth-required', 'input-required', or 'rejected' is implementation-defined but MUST NOT be inferred as a normalized policy refusal.

No JSON-RPC refusal codes (MUST). Implementations MUST NOT invent a JSON-RPC error code carrying refusal semantics; the metadata.mentionable.policy envelope is the sole A2A-side normalized surface. Pre-existing transport JSON-RPC errors (-32600 invalid request, -32700 parse error, A2A’s -32001..-32006 task-lifecycle errors) continue to apply unchanged when they arise legitimately.

Cross-spec dual-emit for payment_required (MUST). PolicyPart and external payment specs may travel in the same A2A task.status.message.metadata. To preserve interop with non-Mentionable consumers of those specs, A2A emitters MUST additionally write the scheme-native carrier whenever an accepted_payments[] entry’s scheme matches:

When dual-emit is in effect, the canonical content MUST be byte-equal to what is carried inside accepted_payments[].payload, where byte-equality is evaluated after RFC 8785 canonicalization on both sides (§3.7). Consumers that detect a mismatch between the normalized and the scheme-native carrier MUST reject the part as malformed.

This dual-emit rule is the only place the spec normatively references external payment-protocol wire formats. Any other parsing of payload (e.g. extracting amount and recipient for display) is the responsibility of an transport module’s design doc, not this spec.

HTTP-bound transport headers (MAY). A2A clients that present a PolicyPart through an HTTP transport MAY surface the following informational headers:

4.3 ActivityPub wire mapping

Non-normative in v0.1.

A normative AP mapping requires resolving signature-binding semantics for HTTP-Signature-authenticated agents and the visibility behavior of Mastodon-class clients (which strip unknown extension properties before display). Both are future work under §6 F-4.

Until F-4 lands, conformant receivers MUST IGNORE mentionable.policy extension properties arriving over ActivityPub (logging at WARN). Emitters MAY still carry the extension for forward-compatibility, but MUST also place part.message in the human-visible Activity content so unsupported clients see at least the user-readable text.

4.4 Email wire mapping

Email transport carries PolicyPart in a reply email keyed to the original mention’s Message-Id via In-Reply-To and References headers. The PolicyPart’s message (or matching message_translations entry) is the email body; url, when present, is included inline.

The mentionable.policy envelope MAY also be carried as an X-Mentionable-Policy header containing a base64-encoded JSON serialization of the envelope, for clients that consume mentionable.* metadata programmatically.

DKIM-aligned authentication (MUST). Receivers MUST NOT honor a mentionable.policy envelope arriving over Email unless the inbound message is DKIM-signed and DKIM-aligned with the agent’s WebFinger-bound domain (RFC 7489 DMARC-style alignment is sufficient). Outbound policy emails carrying actionable url or X-Mentionable-Policy MUST therefore be DKIM-signed by that domain.

This rule is verifiable at the receiver. Operators of self-hosted SMTP MUST configure DKIM at their MTA before claiming spec conformance; mailbox-API-backed adapters (e.g. Gmail) inherit signing from the provider and need only ensure the domain matches.

4.5 REST wire mapping

For agents reached over the REST transport, PolicyPart maps to HTTP status codes and headers per the table below. The metadata.mentionable.policy envelope MAY also be carried in an X-Mentionable-Policy response header (base64-encoded canonical JSON of the §4.1 envelope) for clients that branch on the structured form before parsing the body. When both header and body carry the policy, they MUST agree after canonical serialization (§3.7).

kindHTTP statusRequired headersBody shape
consent_required401 UnauthorizedWWW-Authenticate: Mentionable-Consent realm="<canonical-host>", error_uri="<part.url>"Per content negotiation (§5.3 of transport-rest-v0.1.md). The state query parameter is echoed by the agent’s consent page when redirecting back.
unauthorized401 UnauthorizedWWW-Authenticate: <reconstructed from auth_challenges[]>Per content negotiation.
payment_required402 Payment Required(none beyond Content-Type, Content-Language, X-Mentionable-Agent)Per content negotiation; accepted_payments[] in the body for HTML/JSON consumers.
forbidden403 Forbidden(none)Per content negotiation.
too_many_requests429 Too Many RequestsRetry-After: <retry_after_seconds> (when set)Per content negotiation.
unavailable_for_legal_reasons451 Unavailable For Legal ReasonsLink: <url>; rel="blocked-by" (when url set)Per content negotiation.
service_unavailable503 Service UnavailableRetry-After: <retry_after_seconds> (when set)Per content negotiation.

This is the only transport in v0.1 where PolicyPart kinds map directly onto a status-code-bearing protocol. The naming rule in §3.2 used HTTP status phrases as the primary source for kind names; the REST mapping above realizes those mappings on the wire. Other transports (A2A, Email, ActivityPub) carry the PolicyPart as in-band metadata regardless of their underlying status-code semantics.

Streaming exception. Responses over text/event-stream carry the PolicyPart at HTTP 200 OK regardless of the kind’s otherwise-mapped HTTP status. SSE has already committed 200 OK by the time the agent decides to emit a policy, so the status code mapping above does not apply. The PolicyPart is signaled via the terminal event: policy SSE frame. See transport-rest-v0.1.md §4.3 for the streaming envelope and §4 of that document for the content-negotiated body shape per kind on non-streaming responses.

4.6 Per-platform UI (informative)

How a chat or messaging platform renders the PolicyPart to its end user, where it stores resumption state, how the user navigates back after completing the action — none of that is in this wire spec. That layer is the responsibility of each Connector’s design documentation. Nothing in any design doc modifies any normative text in §3 or §4.

Implementations of new platform Connectors SHOULD publish a parallel design doc under docs/design/ so that protocol-level conformance remains separable from product-level UX iteration.


5. Implementation impact

5.1 Capability declaration

Conformant agents declare PolicyPart support via the agent card’s A2ACapabilities.extensions field, which carries an array of AgentExtension objects per A2A v0.3.0+. The PolicyPart extension’s URI is:

https://mentionable.dev/ns/policy/v0.1

(Deprecated alias: https://mentionable.dev/spec/policy/v0.1.)

This matches A2A’s existing extensions pattern and lets a single agent card carry both Mentionable’s PolicyPart extension and other A2A extensions. (For example, an a2x-conformant agent will also list its a2x extension URI alongside this one; the canonical a2x URI is published in a2a-x402 v0.2.)

The earlier mentionable.policy_v0_1: true boolean form was considered and rejected: A2A’s extensions[] URI list is the ecosystem’s existing capability-declaration pattern, and a URI is forward-compatible with version bumps in a way a boolean key name is not.

A2ACapabilities does not carry extensions[] in the current codebase. Phase 1 includes adding it, mirroring A2A v0.3.0+‘s AgentExtension shape.

5.2 Required helpers in @mentionable/core

The spec text references several helpers that conformant implementations need. Phase 1 ships them all in @mentionable/core so that adapter teams converge:

5.3 Wire-side packages

5.4 Adapter design docs

Each adapter that implements PolicyPart rendering and resumption ships a parallel design doc under docs/design/. The Slack reference design is the first. Claiming the https://mentionable.dev/ns/policy/v0.1 extension on a deployment with an interactive UI surface (Slack, Discord, etc.) REQUIRES a published design doc covering at minimum render and resumption for consent_required and payment_required.

5.5 LOC budget

Realistic counts for Phase 1, including tests and ±20% tolerance:

PackageLOC
@mentionable/core (types, validators, all helpers in §5.2, RFC 8785 conformance test vectors)~500
@mentionable/transport-a2a (encode/decode, dual-emit, switch updates)~300
Agent-card extensions field + validators~80
examples/slack-connector (per its own design doc: policy-state-store, callback route, HMAC + idempotency + return page, slack-rich actions/button helpers, parts-to-slack policy renderer, i18n table, cron, Connector Card endpoint)~1500
examples/llm-agent-vercel (handle inbound metadata.mentionable.policy_resolution from the Slack Connector on follow-up turns)~120
Conformance test vectors per §3.6 (canonical wire JSON, edge cases, RFC 8785 vectors)~200
Total~2700

The wire portions are bounded by this spec; the per-platform UI work is bounded by each transport module’s design doc. Note the bulk of the budget sits in examples/slack-connector because the Slack adapter is a complete reference deployment (callback page, HMAC verification, atomic SQL pattern, Block Kit rendering); other adapters’ ratios will be similar.


5. PolicyResolution (F-3)

This section defines the normalized shape for carrying a verified completion of a payment_required or consent_required PolicyPart back to the agent on the next inbound message turn.

5.1 Design rationale

When a user completes a payment or consent action, the agent needs to know two things: (a) that the action happened, and (b) what was actually paid/consented to, so it can produce the right answer. The challenge is that different transports surface this information in completely different ways:

Rather than defining per-transport wire shapes for every combination, we define a single PolicyResolution that is placed on NormalizedMessage.policy_resolution by whichever component does the final verification — the agent’s own callback endpoint in most cases. The transport is then only responsible for delivering the original payment_required PolicyPart’s url field to the user.

5.2 Trust model

Payment and consent verification is always the agent server’s responsibility. The agent’s callback endpoint (e.g. /api/x402/callback, /api/stripe/webhook) calls the payment provider directly (x402 facilitator, Stripe API, PG API) and only then synthesizes the PolicyResolution.

Adapters are transport relays, not payment verifiers. An adapter MAY add its own internal signing or logging on the resumption path, but the Core spec does not require or validate adapter signatures. HTTPS + single-use state token consumption (issuance_store.consume) is the effective trust boundary.

The verified_by field is for logging and auditing only. Agents MUST NOT branch on it for payment decisions.

5.3 Resumption patterns

Pattern 1 — In-band (A2A, HTTP)

The payment receipt travels in the transport itself. For A2A + x402, the caller places the signed payload in message.metadata["x402.payment.payload"] (per a2a-x402 v0.2). The A2A inbound adapter detects this, calls verifyAndSettle, and sets NormalizedMessage.policy_resolution before invoking the agent.

Pattern 2 — Out-of-band (Email, AP, Slack, Teams, Discord, …)

The transport delivers only the url from the PolicyPart to the user. The user clicks through to the agent’s hosted payment/consent page. On completion, the agent’s HTTP callback endpoint verifies the result directly and then delivers a new NormalizedMessage with policy_resolution set — via whatever transport the original conversation used.

Both patterns produce the same PolicyResolution shape. The agent code sees no difference.

Pattern A — Adapter-direct receipt callback (informative).

A platform adapter MAY rewrite a payment_required PolicyPart’s url to append a receipt_callback query parameter pointing at an adapter-owned endpoint:

https://<agent-host>/pay/<state>?receipt_callback=https%3A%2F%2F<adapter-host>%2Fapi%2Fpayment%2Freceipt

When present, the agent’s pay page POSTs the signed receipt directly to receipt_callback instead of (or in addition to) the agent’s own /api/x402/callback-style endpoint. The body shape is:

{
  "state": "<bearer state from the original PolicyPart>",
  "receipt": {
    /* scheme-defined signed payload, e.g. x402 PaymentPayload */
  },
}

The adapter then re-calls the agent over its own native transport (A2A, REST, …) carrying the receipt under the in-band metadata key the scheme defines (e.g. metadata["x402.payment.payload"] for x402 per a2a-x402 v0.2 §5). This collapses Pattern 2’s two round-trips (agent→adapter signed redirect, adapter→agent A2A re-call) into one adapter-side hop.

Trust gate. The agent’s hosted pay page MUST treat receipt_callback as untrusted user input: only https:// URLs are honored, the state is the bearer token tying the receipt to the issuance row, and the receiving transport module’s verifier (verifyAndSettle on the agent side) MUST run regardless of which path delivered the receipt. Pattern A does not relax single-use — the agent’s issuance store still owns the consume call.

When to choose Pattern A. Pick it when the platform adapter already mediates the user’s chat surface (e.g. Slack ephemerals). Pick Pattern 2 when the user’s surface is the agent itself (REST, direct A2A) — the extra hop buys nothing.

5.4 Wire shape

type PolicyResolution = {
  /** State token from the original PolicyPart. */
  in_reply_to_state: string

  /** Kind of the original PolicyPart. */
  kind: 'payment_required' | 'consent_required'

  /** Verified outcome. Shape depends on `kind`. */
  confirmation: PaymentConfirmation | ConsentConfirmation

  /**
   * Who verified. `"self"` = agent's own callback endpoint (common).
   * An adapter host = adapter mediated verification.
   * For logging/auditing only — agents MUST NOT branch on this.
   */
  verified_by: string
}

type PaymentConfirmation = {
  /** Matches the accepted_payments[].scheme used. */
  scheme: string
  /**
   * The original accepted_payments[].payload verbatim.
   * Agents MAY re-verify via canonical-JSON comparison against
   * their issuance store (defense against amount substitution).
   */
  original_payload: Record<string, unknown>
  /** On-chain tx hash, Stripe payment_intent_id, PG tx ID, etc. */
  transaction?: string
  /** Wallet address, customer email, or PG customer ID. */
  payer?: string
  /** Settlement network (meaningful for x402). */
  network?: string
}

type ConsentConfirmation = {
  /** SHA-256 over canonical JSON of the consented scope. */
  scope_hash: string
  /** ISO 8601 UTC. Absent = indefinite. */
  expires_at?: string
}

The Core TypeScript types are exported from @mentionable/core as PolicyResolution, PaymentConfirmation, and ConsentConfirmation.

5.5 Agent requirements

On receiving a NormalizedMessage with policy_resolution set, the agent (or the PolicyResumptionAgent wrapper) MUST:

  1. Look up in_reply_to_state in its issuance store to confirm it issued this state token and that it has not already been consumed.
  2. Atomically consume the token (consume()) to prevent replays.
  3. Optionally re-verify confirmation.original_payload against the stored accepted_payments[].payload via RFC 8785 canonical-JSON comparison.
  4. Only then invoke the inner agent with the “payment confirmed” context to produce the actual answer.

Steps 1–2 are mandatory. Steps 3–4 are strongly recommended. An agent that skips step 1–2 is vulnerable to replay attacks.

5.6 Adapter requirements

Adapters and callback endpoints that produce a PolicyResolution MUST:

  1. Verify the payment/consent with the authoritative external provider (facilitator, Stripe, PG, consent store) before setting this field.
  2. Set original_payload to the exact accepted_payments[].payload from the agent’s issuance record — not the client-supplied value.
  3. Set verified_by to a stable identifier of the verifying component.

5.7 Scope limitations (v0.1)


6. Future work

The following are deliberately deferred. Each returns when operating signal makes its specific shape clearer.


7. References

RFC 9457 Problem Details crosswalk (informative)

This spec is inspired by but not wire-compatible with RFC 9457 Problem Details. Adapters that need to emit RFC 9457 alongside PolicyPart can convert via this mapping:

RFC 9457 Problem DetailsPolicyPart
type(no direct equivalent; kind is closest semantically)
titletitle
detailmessage
status (HTTP code)derived from kind per the table in §4.2
instanceurl

Adjacent ecosystem specs

The accepted_payments[].scheme namespace is open by design. Implementations supporting these schemes parse payload per the named spec:

Internal context