Skip to content

feat: signals for authorization & abuse #203

Open
igrigorik wants to merge 8 commits intomainfrom
feat/signals
Open

feat: signals for authorization & abuse #203
igrigorik wants to merge 8 commits intomainfrom
feat/signals

Conversation

@igrigorik
Copy link
Contributor

Platforms mediate buyer interaction, making them the sole party able to observe the transaction environment (IP, user agent, etc). Businesses need this data for authorization decisions, rate limiting, and abuse prevention — but have no direct access to it. This PR defines signals contract that allows businesses to dynamically notify platforms of requested signals (via info messages on the response) and for platforms to provide data on the request(s).

  • Signals serve authorization, rate limiting, and abuse prevention across cart and checkout
  • Signals are platform attestations: values MUST reflect direct observations, not relayed buyer claims
  • Signal requests from business are delivered via info messages with risk, abuse codes
  • Extensions can define proprietary signals that can be negotiated and delivered via same mechanism
  1. platform creates checkout session and provides buyer IP for rate limiting and auth:
 POST /checkout-sessions
 {
   "line_items": [{ "product_id": "sku_123", "quantity": 1 }],
   "signals": {
     "buyer_ip": "203.0.113.42"
   }
 }
  1. Business responds — checkout is valid, requests additional data to improve authorization:
  {
    "id": "cs_abc",
    "status": "ready_for_complete",
    "line_items": [...],
    "messages": [
      {
        "type": "info",
        "code": "risk",
        "path": "$.signals.user_agent",
        "content": "User agent improves authorization confidence for high-value orders"
      }
    ]
  }
  1. Platform provides the requested signal on next request:
POST /checkout-sessions/cs_abc/complete
{
  "payment": {
    "instruments": [...]
  },
  "signals": {
    "buyer_ip": "203.0.113.42",
    "user_agent": "Mozilla/5.0 ..."
  }
}

Closes #153.


Checklist

  • New feature (non-breaking change which adds functionality)
  • Documentation update
  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • I have made corresponding changes to the documentation
  • My changes generate no new warnings

@igrigorik igrigorik added this to the Working Draft milestone Feb 23, 2026
@igrigorik igrigorik requested a review from sinhanurag February 23, 2026 06:11
@igrigorik igrigorik self-assigned this Feb 23, 2026
@igrigorik igrigorik requested review from a team as code owners February 23, 2026 06:11
@igrigorik igrigorik added the TC review Ready for TC review label Feb 23, 2026
@amithanda amithanda self-requested a review February 23, 2026 06:32
"update": "optional",
"complete": "optional"
},
"ucp_response": "omit"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you comment on why we don’t reflect these back in the response? I am not going to die on this hill, but as I mentioned in our initial discussion, I find it odd that this is one of the few platform-provided fields they would need to keep the state of themselves, since they can’t read the current values from a get_checkout call.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You found the honeypot.

I don't have a strong philosophical objection to removing omit annotation on response, exactly for the reasons you mentioned. However, if you peek two lines above, you'll see that we have same pattern on context object and hence why I replicated the pattern here. I think we can make the exact same argument for context, and perhaps we should. Let's discuss on the TC call tomorrow.

Copy link
Contributor

@raginpirate raginpirate left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two strong opinions I'll throw your way! Sadly I missed the breakout on this due to flying, so lmk if there was already consensus on these topics; if there was, at least we can document why my thoughts are wrong 😄

@douglasborthwick-crypto

The signals-as-platform-attestations model is clean. One extension pattern worth considering: cryptographic third-party attestation signals.

The current examples (buyer_ip, user_agent) are first-party platform observations. For verticals like token-gated commerce or compliance-gated checkout, businesses also need authorization signals derived from on-chain state — whether the buyer's wallet holds qualifying tokens, or carries a KYC attestation from a verifiable issuer (e.g., Coinbase Verified Account via EAS on Base).

These aren't relayed buyer claims. They're ECDSA-signed boolean results from an independent verifier, checkable against a published JWKS endpoint. They satisfy the "MUST reflect direct observations" requirement because they reflect the verifier's direct observation of chain state at a specific block height.

A possible shape using the extension mechanism:

{
  "signals": {
    "buyer_ip": "203.0.113.42",
    "ext.attestation": {
      "provider_jwks": "https://provider.example/.well-known/jwks.json",
      "pass": true,
      "attested_at": "2026-02-27T12:00:00Z",
      "signature": "base64-p1363..."
    }
  }
}

The interesting property: unlike platform-observed signals, cryptographic attestation signals are self-verifying. The business validates the signature locally without trusting the platform or calling back to the attestation provider. This actually strengthens the trust model — the business gets a signal it can independently audit.

We operate a UCP-compatible attestation service that issues ECDSA P-256 signed verification results for on-chain conditions across 31 chains. The info message → signal request → signal delivery flow you've defined here maps well to how attestation requests work in practice: business requests an on-chain check via risk code message, platform obtains the attestation, delivers it as a signal on the next request. Happy to share production learnings if they'd help inform the extension semantics.

@igrigorik
Copy link
Contributor Author

@raginpirate extracting from comment thread so it's more prominent...

I disagree with this proposal for MUST use reverse-domain naming.

If our intention for this to be done the UCP-way, its via an extension which is properly namespaced and extends the signals field, and could let the business communicate with the platform what it necessitates.

If our intention is for this to be done out-of-band, then the platform and agent have formed a relationship and can land on unique field names with validation around their known identities with one another; again, no need to enforce namespacing on the field.

Yes, the extensions are namespaced (dev.acme.risk), but that doesn't automatically propagate to the keys it registers into the shared signals object. The "out-of-band parties can agree on names" is true for bilateral relationships, but UCP is many-to-many — a platform serves multiple businesses, each with different extension combinations.

To your point though, why here and not for other extensions? Most UCP extension points are structurally collision-safe: capability registries key by reverse-domain name, arrays (messages, discounts) keep items independent. signals is different — it's a shared flat map where multiple independent extensions contribute keys into the same object. dev.acme.risk and dev.foo.fraud both have a concept of "device id" but differ in definition and implementation; both need to coexist without collision.

Could we relax to a SHOULD? We could, but even seemingly safe shared names have hidden footguns: one extension declares ja4 as a string, another as an object carrying additional metadata — the composed allOf schema becomes silently unsatisfiable. Another way to address this would be to model signals as an array, which prevents top-level key collisions but punts the problem to a different layer: access requires filtering, and without a naming convention you can still end up with multiple ja4 entries you'd have to distinguish between, with an undefined contract on how to figure out who provided which.

I think leaning into a MUST is the right contract here, and now that I've typed it out, I think we should make it schema enforceable — require reverse-dns on propertyNames, same as we do for handlers and services. A flat map with propertyNames validation gives us both collision safety with direct key access, and every key carries provenance: you know exactly who defined it via key name, and we explicitly prevent collisions. In this setup, buyer_ip becomes dev.ucp.buyer_ip and we enforce + validate all fields, no carve-outs.

See: f1baa9a

  Clean up all risk_signals language from the earlier draft that was
  superseded by the signals proposal (issue #153).
  Platforms mediate buyer interaction, making them the sole party
  able to observe the transaction environment (IP, user agent, etc).
  Businesses need this data for authorization decisions, rate limiting,
  and abuse prevention — but have no direct access to it.

  - Signals are platform attestations: values MUST reflect direct
    observations, not relayed buyer claims
  - Signals serve authorization, rate limiting, and abuse prevention
    across cart and checkout
  - Signal feedback via info messages with well-known codes: risk, abuse
  - Proprietary signals can use reverse-domain naming (com.example.score)
  Unlike other UCP extension points — capability registries key by
  reverse-domain name, arrays keep items independent — signals is a shared
  flat map where multiple independent extensions contribute keys into the
  same object. Without naming discipline, collisions are inevitable: two
  extensions both defining "device_id" or "ja4" with different types or
  semantics produces silently unsatisfiable allOf composition.

  Reverse-domain naming makes every key self-describing: you know who
  defined it, what schema governs it, and collisions are structurally
  impossible. This is the same coordination-free mechanism UCP already
  uses for capabilities, services, and payment handlers.

  This commit enforces the MUST at the schema level via propertyNames
  (not just prose), renames well-known signals into the dev.ucp namespace
  (buyer_ip → dev.ucp.buyer_ip, user_agent → dev.ucp.user_agent), and
  updates all docs and examples to match.
  The original spec language required signals to be "direct platform
  observations," which excluded a valuable class of signals: independently
  verifiable third-party attestations (e.g., cryptographically signed
  results from an external verifier). These aren't buyer-asserted claims —
  the business can validate the signature against the provider's published
  key set without trusting the platform at all.
@igrigorik
Copy link
Contributor Author

@douglasborthwick-crypto great call out, agree and updated spec language: 7d50b2c

@igrigorik igrigorik requested a review from a team as a code owner March 3, 2026 17:50
@douglasborthwick-crypto
Copy link

Thanks Ilya — the language shift to "independently verifiable third-party attestations" is the right framing. The trust guarantee comes from cryptographic verifiability, not from who observed the data.

Two notes on the example shape in the updated spec:

1. kid field alongside provider_jwks

The com.example.attestation object should include a kid (key ID) alongside provider_jwks. This is standard JWKS practice (RFC 7517 §4.5) — it lets the business select the correct public key directly without fetching and iterating the full key set, and is necessary for key rotation to work cleanly.

2. The signal needs the full signed payload to be self-verifying

For the business to actually verify the signature locally, the signal must include the complete signed payload — not just pass. In our case the signed payload is {"id", "pass", "results", "attestedAt"} (fixed field order, Section 3.3 of our attestation spec). Without id, results, and attestedAt, the business can't reconstruct the payload and verify sig. A signal carrying only pass + signature requires trusting the platform's relay — exactly the dependency the self-verifying property is supposed to eliminate.

Here's what a verifiable attestation signal looks like with reverse-domain naming:

"com.insumermodel.attestation": {
  "provider_jwks": "https://api.insumermodel.com/v1/jwks",
  "kid": "insumer-attest-v1",
  "id": "ATST-A7C3E",
  "pass": true,
  "results": ["...per-condition evaluations with conditionHash for tamper evidence..."],
  "attestedAt": "2026-03-03T12:00:00Z",
  "sig": "MEYCIQDx...base64..."
}

The business verifies by: fetching JWKS from provider_jwks, selecting the key matching kid, reconstructing the signed payload from {id, pass, results, attestedAt}, and verifying sig over its SHA-256 digest. Zero trust in the platform required. The conditionHash in each result additionally lets them confirm exactly what on-chain condition was evaluated (contract, chain, threshold) without that being alterable post-signing.

Happy to contribute an extension schema for this once the PR lands.

Copy link
Contributor

@raginpirate raginpirate left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the POV, I think thats fair although I still would have loved to allow collisions purposefully to re-use simple integrations with simple field names. But I get your point that the typing problem can become hard if integrators overload with poor comparison on the base types (like two string ids).

I also flagged internally for a quick review on those code names to make sure we are not giving away too much info for bad actors with codes like "risk" and "abuse".

  Updated shape includes kid (RFC 7517 key selection/rotation), the
  complete signed payload (opaque, extension-defined), and sig over that
  payload. The business can now verify end-to-end: fetch JWKS, select
  key by kid, verify sig over payload.
@igrigorik
Copy link
Contributor Author

@douglasborthwick-crypto good call, restructured the example to carry the full signed payload + sig, so the shape is self-verifiable. The goal here is to provide an illustrative example, the actual payload is specific to the provider and, as you outlined, is deferred to the extension that provides the signal+attestation.

@github-actions
Copy link
Contributor

github-actions bot commented Mar 3, 2026

Super-linter summary

Language Validation result
BIOME_LINT Pass ✅
GIT_MERGE_CONFLICT_MARKERS Pass ✅
JSON Pass ✅
MARKDOWN Pass ✅
OPENAPI Pass ✅
PRE_COMMIT Pass ✅
TRIVY Pass ✅

All files and directories linted successfully

For more information, see the
GitHub Actions workflow run

Powered by Super-linter

  Replace the risk/abuse code taxonomy with a single `signal` code.
  The path field already identifies what's being requested, and the
  signal is self-describing.

  Removes info-only language to allow and support different business
  strategies for information vs required signals.
@github-actions
Copy link
Contributor

github-actions bot commented Mar 4, 2026

Super-linter summary

Language Validation result
BIOME_LINT Pass ✅
GIT_MERGE_CONFLICT_MARKERS Pass ✅
JSON Pass ✅
MARKDOWN Pass ✅
OPENAPI Pass ✅
PRE_COMMIT Pass ✅
TRIVY Pass ✅

All files and directories linted successfully

For more information, see the
GitHub Actions workflow run

Powered by Super-linter

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

TC review Ready for TC review

Projects

None yet

Development

Successfully merging this pull request may close these issues.

EP: risk & abuse signals

5 participants