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.mdand issue #503) the legacy URIhttps://mentionable.dev/spec/policy/v0.1is 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:
- “You haven’t agreed to my terms — go to this URL and accept first.”
- “This action requires payment. Here are the accepted methods.”
- “I won’t answer because this is restricted in your jurisdiction.”
- “You’re rate-limited. Try again in 60 seconds.”
- “This service is temporarily unavailable.”
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:
- 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). - If no HTTP status fits cleanly, the well-known token from the
most-deployed adjacent protocol (e.g. OIDC’s
consent_required). - Inventing a new name only when neither (1) nor (2) fits. This has not been used in v0.1.
The mapping for each kind:
kind | Source | |
|---|---|---|
consent_required | OIDC error token | rule (2) |
unauthorized | HTTP 401 | rule (1) |
payment_required | HTTP 402 | rule (1) |
forbidden | HTTP 403 | rule (1) |
too_many_requests | HTTP 429 | rule (1) |
unavailable_for_legal_reasons | HTTP 451 | rule (1) |
service_unavailable | HTTP 503 | rule (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.
3.3.4 consent_required
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:
- The part MUST appear in the final frame of the stream.
- The agent MUST stop generating after the part is emitted.
- A2A:
task.status.state === 'input-required'(forconsent_requiredandpayment_required) or'failed'(for the others), withfinal: true.
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
| Situation | Required behavior |
|---|---|
kind: 'unauthorized' with empty/missing auth_challenges | Validator rejects as malformed. |
kind: 'too_many_requests' with no retry_after_seconds | Valid; HTTP adapter omits header; user-facing message says “try again later”. |
kind: 'payment_required' with empty/missing accepted_payments | Validator rejects as malformed. |
kind: 'consent_required' without state or return_to | Validator 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 / prototype | Implementation strips and continues. |
data or payload contains unprefixed keys | Verifier ignores them; emitter is non-conformant. |
| Extension URI declared but emitted PolicyPart fails validation | Receiver 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:
- Object keys sorted by their UTF-16 code-unit code-point order (RFC 8785 §3.2.3).
- Optional fields omitted entirely when absent (no explicit
null). - Numbers serialized per ECMAScript 2023 §6.1.6.1.13 (also RFC 8785 §3.2.2.3).
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:
kind | A2A 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:
-
schemestarts withx402.→ MUST also setmetadata.x402.payment.status: "payment-required"andmetadata.x402.payment.required: <payload>per a2a-x402 v0.2. When multipleaccepted_payments[]entries are x402-flavored, the first one’spayloadis mirrored. (Subsequent x402-flavored options remain in the normalizedaccepted_payments[]for Mentionable consumers.) -
schemestarts withap2.→ MUST also emit each AP2 mandate as akind: 'data'part inside its owntask.artifacts[]entry per the AP2 specification, with the artifact’sdatafield keyed by the AP2 mandate type ('ap2.mandates.CartMandate','ap2.mandates.PaymentMandate'). When multipleap2.*entries are present, each emits its own artifact entry; for byte-equal comparison purposes, the first artifact whosedatais keyed by the matchingap2.mandates.*type is the canonical source.AP2 directional note. Of AP2’s three mandate types, only
CartMandateandPaymentMandateare server-emitted (the merchant attests the offer and the payment intent respectively).IntentMandateis buyer-emitted (the buyer attests their shopping intent) and SHOULD NOT originate from an agent’saccepted_payments[]. Future revisions of this spec will define a buyer-side carrier when delegation (F-1) lands.
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:
unauthorized→WWW-Authenticatereconstructed fromauth_challenges[].too_many_requestsandservice_unavailable→Retry-After: <retry_after_seconds>when set.unavailable_for_legal_reasons→Link: <url>; rel="blocked-by"per RFC 7725.
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).
kind | HTTP status | Required headers | Body shape |
|---|---|---|---|
consent_required | 401 Unauthorized | WWW-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. |
unauthorized | 401 Unauthorized | WWW-Authenticate: <reconstructed from auth_challenges[]> | Per content negotiation. |
payment_required | 402 Payment Required | (none beyond Content-Type, Content-Language, X-Mentionable-Agent) | Per content negotiation; accepted_payments[] in the body for HTML/JSON consumers. |
forbidden | 403 Forbidden | (none) | Per content negotiation. |
too_many_requests | 429 Too Many Requests | Retry-After: <retry_after_seconds> (when set) | Per content negotiation. |
unavailable_for_legal_reasons | 451 Unavailable For Legal Reasons | Link: <url>; rel="blocked-by" (when url set) | Per content negotiation. |
service_unavailable | 503 Service Unavailable | Retry-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.
- Slack Connector:
docs/design/slack-policy-resumption.md - (Future: Discord, Teams, Telegram, Mastodon Connectors as their designs are written.)
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:
PolicyParttypes and per-kind validators per §3.6.canonicalStringify(value)per RFC 8785. The existing module-privatestableStringifyinagent-card.tsis hoisted topackages/core/src/canonical-json.tsand re-exported.validateAuthChallenge(challenge)per §3.3.1’s RFC 9110 charset rules.sanitizePolicyData(value)returning a null-prototype object that strips__proto__/constructor/prototype. Adapters MUST route inbounddataandpayloadthrough this helper.getCanonicalHost(jrd: WebFingerJRD): stringreturning the host portion oflinks[rel=self].href(or a fallback per §3.2 for agents discovered without WebFinger).validateUrlOrigin(url: string, canonicalHost: string): booleanapplying the §3.2 host-comparison procedure (case-fold, IDN, trailing-dot, default port, userinfo rejection).findPolicyPart(parts): PolicyPart | undefinedfor stream termination detection.policyKindToA2AState(kind): A2ATaskStatedriving the §4.2 state mapping.
5.3 Wire-side packages
@mentionable/transport-a2a: encode/decode per §4.1 and §4.2, including the dual-emit behavior forx402.*andap2.*schemes. The existing exhaustivePart-kind switches ininbound.ts,outbound.ts, andcard.tsare extended for the seven new kinds;PolicyPart.kindis treated as an open string type at the wire level (TypeScript shape:kind: PolicyKind | (string & {})) so unknown kinds round-trip.@mentionable/transport-email: the inbound DKIM-alignment rule per §4.4 sits at the email-receive boundary; outbound DKIM is the operator’s responsibility (Gmail-API-backed adapters inherit this from the provider).
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:
| Package | LOC |
|---|---|
@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:
- A2A: the receipt can travel in-band via
message.metadata. - Email / ActivityPub: there is no standard mechanism; a receipt attached to a reply would be fragile and UX-hostile.
- Slack / Teams / Discord: a platform adapter mediates the entire flow and needs to forward the completion back over A2A.
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:
- Look up
in_reply_to_statein its issuance store to confirm it issued this state token and that it has not already been consumed. - Atomically consume the token (
consume()) to prevent replays. - Optionally re-verify
confirmation.original_payloadagainst the storedaccepted_payments[].payloadvia RFC 8785 canonical-JSON comparison. - 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:
- Verify the payment/consent with the authoritative external provider (facilitator, Stripe, PG, consent store) before setting this field.
- Set
original_payloadto the exactaccepted_payments[].payloadfrom the agent’s issuance record — not the client-supplied value. - Set
verified_byto a stable identifier of the verifying component.
5.7 Scope limitations (v0.1)
- One resumption per message turn. Multiple sequential payments are modelled as separate turns each carrying their own signal. Simultaneous multi-payment is out of scope.
HistoricalMessagedoes not carrypolicy_resolution. The signal is action-oriented (process this now), not archival. Durable payment records belong in the agent’s database (issuance store, audit log), not in the conversation history shape.- Adapter signatures are not standardized. Adapters MAY sign their resumption forwarding internally (as the Slack Connector does with Ed25519), but the Core spec does not define or validate this. HTTPS + state token single-use is sufficient for most deployments; adapter signing is an optional hardening layer.
6. Future work
The following are deliberately deferred. Each returns when operating signal makes its specific shape clearer.
-
F-1: Delegation. A signed envelope letting a personal agent act on its owner’s behalf (H2A2A). Two prior internal drafts attempted this; they are archived under
docs/proposals/archive/. When F-1 lands, the delegation envelope will be a new top-level Part kind, not a newaccepted_payments[].scheme— the opaque payload slot is for payment-protocol carriers, not for delegation proofs. -
F-2: Agent-side
payment_requiredintegrity. A normalized signature field overaccepted_payments[]for relayed transports that do not authenticate the agent at the channel layer. -
F-3: PolicyResolution.
A normalized companion shape for echoing resolutions (payment receipts, consent tokens, access tokens) on the next inbound message.Implemented. See §5 andnormalized-message.md §3.4.NormalizedMessage.policy_resolutioncarries the verified completion of a prior PolicyPart. The Core type isPolicyResolutionin@mentionable/core. Transport-specific wire encoding of the receipt (how the adapter or callback endpoint assembles the signal) remains adapter-private; the Core interface is transport-agnostic. -
F-4: ActivityPub normative mapping. §4.3 leaves AP non-normative because anonymous senders and HTTP-Signature key semantics need additional work.
-
F-5: Cross-platform resumption pattern. Partially addressed by F-3 (the
PolicyResolutioninterface is cross-platform by design). Adapter-specific rendering and callback wiring still lives in per-adapter design docs underdocs/design/. -
F-6: Mid-stream PolicyPart. Agents that decide late in a generation that a refusal is required currently must terminate cleanly. If this proves common, a partial-stream policy admission with explicit transactional semantics may follow.
-
F-7: Prepaid credit and auto-deduction. Nothing in the Core spec changes. The pattern is implemented entirely at the agent application layer:
-
Credit deposit. When a payer overpays (x402, Stripe, PG), the agent records the surplus in its own ledger keyed by
PaymentConfirmation.payer. Subsequent turns deduct from this balance without emittingpayment_required. -
Auto-deduction consent. Before the first deduction the agent emits a
consent_requiredPolicyPart whosescopeobject encodes the per-turn and period caps the user is consenting to, e.g.:{ "auto_deduct": true, "per_turn_max_usd": "0.05", "period_max_usd": "10.00", "period": "monthly" }The
scope_hashin the resultingConsentConfirmationkeys the consent cache. As long as a turn’s projected cost falls within the consented caps, the agent deducts silently. When the period cap is reached or a single turn would exceedper_turn_max, the agent emits a freshconsent_required(orpayment_requiredfor a top-up) before proceeding. -
Traditional payment integration. Stripe and Korean PG schemes participate identically — the agent reads
PaymentConfirmation.schemeandtransaction, queries the payment provider to confirm the settled amount, and credits the ledger. The Core wire format is unchanged; only the agent’s ledger and consent-check logic differ per scheme.
The first reference implementation of this pattern is in
examples/llm-agent-vercel. No Core types need to be extended for F-7. -
7. References
- RFC 2119 — Key words for use in RFCs to Indicate Requirement Levels
- RFC 8174 — Ambiguity of Uppercase vs Lowercase in RFC 2119 Key Words
- RFC 7033 — WebFinger
- RFC 5895 — Mapping Characters for Internationalized Domain Names
- RFC 5952 — A Recommendation for IPv6 Address Text Representation
- RFC 6750 — OAuth 2.0 Bearer Token Usage
- RFC 7489 — Domain-based Message Authentication, Reporting, and Conformance (DMARC)
- RFC 7725 — HTTP 451 Unavailable for Legal Reasons
- RFC 8785 — JSON Canonicalization Scheme (JCS)
- RFC 9110 — HTTP Semantics (obsoletes RFC 7230 and RFC 7235)
- RFC 9457 — Problem Details for HTTP APIs (obsoletes RFC 7807)
- BCP 47 — Tags for Identifying Languages
- OIDC Core §3.1.2.6 — Authentication Error Response (source of
consent_required) - UTS #39 — Unicode Security Mechanisms (confusable detection guidance)
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 Details | PolicyPart |
|---|---|
type | (no direct equivalent; kind is closest semantically) |
title | title |
detail | message |
status (HTTP code) | derived from kind per the table in §4.2 |
instance | url |
Adjacent ecosystem specs
The accepted_payments[].scheme namespace is open by design.
Implementations supporting these schemes parse payload per the
named spec: