INAM Protocol — Specification v0.38 (Draft)
Status: Draft. This describes two behaviorally-identical reference implementations in this repository: /src (Node/Express, node:sqlite storage) and /worker (Cloudflare Workers, Hono + D1 + KV — live at https://api.inamprotocol.org). Both share the same crypto core (sdk-js/src/crypto/, sdk-js/src/core/receiptContent.ts — published standalone as the inamprotocol npm package) so there is one source of truth for signing/canonicalization regardless of runtime. Anything below not yet enforced by that code is explicitly marked "not yet enforced" — this document tracks what is real, not what is aspirational.
Changes from v0.37: an INAM receipt can back ERC-8004 feedback (§11.1). The receipt's requester sends giveFeedback from the EVM address its INAM ID proved control of, and the feedback file carries the signed receipt, so a reader can tell task-linked, two-party feedback from a bare score sent by any wallet. No INAM server or wire change; sdk-js 0.17.0 adds buildErc8004Feedback and verifyErc8004Feedback.
Changes from v0.36: an A2A agent can carry its INAM reputation on its Agent Card (new §11.3). A data-only A2A extension, https://inamprotocol.org/ext/a2a/v1, names the agent's INAM ID; a client accepts it only if that ID linked one of the card's endpoints as its a2a_endpoint, so the card and the ID must name each other. No INAM server or wire change; sdk-js 0.15.0 adds inamA2AExtension and verifyA2ACard.
Changes from v0.35: a payer can now check an x402 payee's INAM reputation before paying (new §11.2). The payee names its INAM ID in an inam extension on its x402 v2 PaymentRequired object, and the payer pays only payTo addresses that ID proved control of through an erc8004_id link (§2.1), and only if its evidence and score meet the payer's policy. The binding check is the point: without it, any server could name a reputable agent's ID and collect the payment itself. No server, endpoint, or wire change to INAM; sdk-js 0.14.0 adds withInamX402Gate, decideX402, and inamX402Extension. Separately, the external monitor now timestamps each new tree head in Bitcoin through OpenTimestamps (§13.4).
Changes from v0.34: the evidence behind a reputation is now reported along separate dimensions, following the evidence-strength vocabulary under discussion in the AAIF Identity & Trust working group (aaif/wg-identity-and-trust#5). evidenceLevel is a single ordered scale and can't express, for example, "countersigned and logged, but the verifier that attested it has since lost its authorization". A new top-level evidence object (§5.3) counts receipts by source (declared, corroborated, independentlyVerified), by construction (anchored: has a transparency-log leaf), and by freshness (evaluatedAt, and excludedAttestations: Verifications ignored because their verifier is no longer authorized). evidenceLevel stays, unchanged. New §5.4 states what both fields always were: the registry's own appraisal, a hint. A relying party that needs a finding re-derives it from signed records, and §5.4 says how.
Changes from v0.33: a data-protection review of the transparency log (§13) found two problems with embedding each event's payload directly in its permanent leaf. (1) participants_only receipts leaked through the log. §4.4 hides such a receipt from GET /receipts/:id, but its receipt_finalized leaf carried the full receipt, and GET /transparency/entries served it to anyone. (2) Free text became undeletable. A dispute's reason, a resolution note, and a non-performance reason went into leaves that can never change, so personal data or an unfounded accusation typed there could not be erased when data-protection law (GDPR Art. 17, KVKK Art. 7) or a court requires it. Since v0.34 a leaf's entry commits to its payload by hash (dataHash) instead of containing it, and the payload is stored beside the leaf. It is withheld for participants_only receipts and erasable by the operator, and every proof stays valid either way (§13.1, §13.3). A dispute reason is now capped at 500 characters like the other free-text fields. Leaves appended before v0.34 keep the old embedded format, since the log is append-only; on the live registry they are the maintainers' public reference receipts.
Changes from v0.32: the remaining operational items from the same external evaluation. Nothing here changes a signed format; the only wire change is one new search parameter. (1) Demo agents are out of discovery by default (§2, §6). The evaluation counted the live registry's "network activity" and found most of it was quickstart and reference agents. A registrant can now declare metadata.demo: true (quickstarts, tutorials, smoke tests — every example in this repository now does), and GET /agents/search omits such agents unless include_demo=true is passed; both SDKs gained the parameter (includeDemo / include_demo). Only a boolean true counts, and nothing else changes: a demo agent's record, receipts, and reputation stay fully readable. metadata.reference: true marks maintainer-seeded agents, which stay discoverable but are labelled in the explorer. Both are self-declared, so they can only ever hide or label an agent's own registration. worker/backfill-demo-reference-tags.sql tags the agents registered before the convention existed. (2) A live, operator-authorized verifier (§12.8). The evaluation found zero independent verifications on the live registry, so the attestation machinery had never carried real weight. scripts/integrity-verifier.ts is the maintainers' own shape-2 verifier, run hourly from GitHub Actions. It walks the transparency log's receipt_finalized entries, fetches each receipt's outputUri, and signs rejected if the bytes don't hash to outputHash. On a match it signs verified in full mode, or signs nothing in reject-only mode, a switch that stops it from ever adding a boost. Its scope is stated in its registry profile (checks: ["output_integrity"]): it attests that the signed output exists and is intact, not that it is correct. Checking correctness per capability is planned as a follow-up. The reference seed receipt's output is now published at https://inamprotocol.org/reference/review-notes.txt so there is something real to check. (3) An external transparency-log monitor (§13). v0.30's tamper-evidence depends on someone outside the registry keeping old tree heads, and nobody did. scripts/sth-monitor.ts records every head it sees, proves each new one is a consistency-checked extension of the last, and re-hashes and inclusion-checks every new entry. It runs hourly beside the verifier, and its history is public on the monitor-state branch. Anyone can run the same script against their own copy of that history.
Changes from v0.31: an external evaluation ran the full lifecycle through the MCP server and found the reputation score could say the opposite of the evidence behind it. Four fixes, all aimed at making the score honest rather than adding new trust machinery. (1) A rejected Verification now counts against the work (§12.5). An authorized verifier rejected a receipt's output, yet the provider's trustScore still rose (8.0 → 10.6) and successRate stayed at 100%: v0.9–v0.31 said a rejected Verification "MUST NOT apply any weight change", so the rejection was recorded but invisible in every number a consumer actually reads. A receipt whose counted Verifications net out to rejected (strictly more rejected than verified, same tiebreak and same currently-authorized/non-revoked filter as the boost) now scores as a failed outcome regardless of the verification.outcome its two parties self-declared. This is safe to act on only because verifier status is an explicit operator grant (v0.10) — a self-registered identity still can't file a counted rejection. New components.rejectedAttestations and flag attestation_rejected. The helper both runtimes share is now attestationVerdict() (returns verified/rejected/none) in place of the boolean hasVerifiedAttestation(). Deliberately not done: auto-dispute on rejection and verifier-side reputation stay deferred (§12.7), and baseTrust's cheap counterparty estimate still reads the self-declared outcome only. (2) specHash/outputHash must be real content hashes (§4.1). The live seed receipt carried sha256:review_notes_v1 — a label, signed by both parties, committing to no content anyone could check. All four hash fields (POST /jobs specHash, a receipt's task.specHash/result.outputHash, a Verification's outputHash) now MUST match ^sha256:[0-9a-f]{64}$; anything else is VALIDATION_ERROR. Stored records are not rewritten or re-validated on read. Every example, seed script, and the quickstart now hash actual content via the SDK's existing sha256Hex, and the MCP server gains an inam_hash_content tool so an LLM-driven agent can produce a valid hash without a shell. (3) evidenceLevel (§5.3). A new top-level reputation field — none, countersigned, or independently_verified — so a consumer can't mistake a two-party-only history for independently checked work by reading trustScore alone; the explorer shows it next to the score. components.finalizedReceipts is added as the honest name for what verifiedReceipts always meant; verifiedReceipts stays as a deprecated alias with the same value. (4) API-reference drift. openapi.yaml (the published API reference) omitted the four /transparency/* endpoints and POST /agents/:id/verifier-status, and §6's table omitted GET /health, the badge endpoints, and POST /jobs/:id/report-nonperformance. Both now list exactly the Worker's route table, and a new test (tests/apiSurfaceDrift.test.ts) fails CI if any of the three disagree again. Wire-breaking only for (2); no D1 migration.
Changes from v0.30: a draft receipt's dispute.windowClosesAt is now null until countersigning sets it (§4.3), instead of the empty string "" the reference implementations used to return. "" wasn't a valid date-time (it contradicted openapi.yaml's own format: date-time, so a strict schema validator rejected every draft), and null is the one unambiguous "not set yet" value. dispute stays outside both the signed content and the receiptId hash, so no signature or ID changes. Existing drafts are rewritten in place: the Node reference server does it automatically on startup, and the Worker needs worker/migration-draft-window-null.sql, which is idempotent and safe to run before or after deploying. For clients, the key stays present (r["dispute"]["windowClosesAt"] still works) and both old and new values are falsy, so an if windowClosesAt check behaves the same. Both SDKs add disputeWindowClosesAt/isDisputeWindowOpen (Python: dispute_window_closes_at/is_dispute_window_open), which return "no window" for null rather than a 1970 date and also tolerate a pre-v0.31 registry's "".
Changes from v0.29: round-2 item 6 — an append-only transparency log (§13) for the receipt lifecycle, RFC 6962-style (the Certificate Transparency Merkle tree design). Every receipt_finalized, dispute_opened, dispute_resolved, and nonperformance_reported event now appends one leaf to a per-registry log; four new read endpoints (GET /transparency/sth, /entries, /proof/inclusion, /proof/consistency) let any caller independently verify that a receipt's history hasn't been retroactively edited, reordered, or deleted — the property plain database mutation can't offer. New shared sdk-js/src/core/merkleLog.ts (proof generation/verification, both runtimes import it) and sdk-js/src/core/transparencyLog.ts (log-entry canonicalization); Python gets a verification-only port (sdk-python/inamprotocol/merkle_log.py) so a caller in either language can check a proof itself rather than trusting the registry's arithmetic. New error codes INVALID_TREE_SIZE, INVALID_LEAF_INDEX. New D1 table transparency_log (worker/migration-add-transparency-log.sql — MUST run before deploying this version's Worker code) and a matching node:sqlite table on the Node side. The log's root is served unsigned: the registry holds no signing keypair of its own (§7 only verifies operator-signed requests), so the tamper-evidence guarantee comes from consistency proofs between two client-observed tree heads, not a signature on any single one.
Changes from v0.27: round-2 item 7 — GET /agents/search and GET /jobs/search had no result-set bound at all; the Worker's D1 query for jobs ran with no LIMIT clause whatsoever, so a large match could risk a response-size failure rather than a bounded page, and the same gap existed for agent search once min_reputation/job-visibility post-filtering was applied. Both endpoints now accept optional limit/offset query params (default limit=50, clamped to a max of 200; an invalid/garbage value falls back to the default rather than erroring) and both responses gain a hasMore boolean. Pagination is applied after any post-fetch filtering (job participants_only visibility, min_reputation) so a page reflects what the caller actually sees — a page can come back shorter than limit near the end of the result set while hasMore is still false. Purely additive: existing callers that never send limit/offset keep getting the same first-50 page shape as before, no wire break. New shared sdk-js/src/core/pagination.ts (both runtimes import it, one source of truth for the clamp/default logic); searchAgents()/searchJobs() in both SDKs gain optional limit/offset passthrough params. No D1 migration, no SPEC-level breaking change.
Changes from v0.26: §12.5's "currently operator-authorized" check (v0.22) had a gap: POST /agents/:id/verifier-status (the operator's own revoke path, §12.6) MUST NOT operate on an already-revoked agent (§2.2 predates this and already enforces that — AGENT_REVOKED), so once a verifier revokes itself (§2.2, a self-service action requiring no operator involvement), its isAuthorizedVerifier flag is frozen at whatever it was and the operator has no remaining path to clear it. hasVerifiedAttestation only ever checked isAuthorizedVerifier, so a self-revoked verifier's already-submitted verified record kept applying the boost forever, permanently outside operator control — undermining §2's "verifier eligibility is an explicit operator grant" guarantee for exactly the case (a compromised or malicious verifier) that guarantee exists to cover. Fixed in both runtimes' hasVerifiedAttestation (src/services/verificationService.ts/worker/src/verificationService.ts): a verification record now also requires its verifier to not be revoked, checked alongside isAuthorizedVerifier, both at computation time. No wire/D1 change; reference-implementation-only, same pattern as the v0.22 fix it closes the gap in.
Changes from v0.25: a batch of six small hardening fixes an external review found, none wire-breaking (a seventh finding — binding the request signature to the registry's own host identity — is a genuine wire-breaking change deferred to a future version pending a design decision, not included here). (1) Small-order Ed25519 key rejection. A degenerate ("small-order"/low-order-torsion) public key registers as a valid did:key with no rejection anywhere, and for such a key the standard Ed25519 verification equation is satisfiable by arbitrary signature bytes with no private key at all — anyone could "sign" as that identity. sdk-js/src/crypto/keys.ts's verify()/verifyRawEd25519() now reject a small-order public key (Point.fromHex(key).isSmallOrder()) before checking any signature against it, closing registration, receipt/verification signatures, and link-challenge proofs at once, since every raw-key verification in this codebase routes through these two functions. (2) Replay-guard base64 non-canonical re-encoding. §7's replay guard bound a verified signature to its Idempotency-Key by hashing the raw inam-signature header string — but a base64-encoded 64-byte Ed25519 signature has unused low bits in its final character that a decoder ignores, so a captured signature can be re-encoded into a different string that decodes to the identical bytes, bypassing the guard entirely (one captured request reproducibly minted many extra jobs this way). Fixed by hashing the decoded signature bytes, not the header string, in both runtimes' replay-guard middleware. (3) Draft-receipt spam / unconsented public exposure. A draft receipt (§4.3 step 1) is only agentB's (the drafter's) own claim — agentA hasn't acted on it yet — but it was fully public and countable regardless of its own visibility field, letting anyone name any registered agent as agentA on an unbounded number of drafts, all immediately, publicly visible with zero involvement from the named agent (300 garbage "failed" drafts reproduced in one pass). §4.4's isReceiptRestricted (shared, sdk-js/src/core/receiptVisibility.ts) now also restricts any status === "draft" receipt regardless of its visibility, closing GET /receipts/:id and GET /agents/:id/receipts in both runtimes with no route-layer change; §5.3's rawReceipts now excludes drafts (a receipt only counts once both parties have acted on it, matching §4.3 step 1's existing "MUST NOT count a draft receipt toward reputation" rule — this was already true for trustScore, just not for the raw count). (4) participants_only leak via the linked Job record. §4.4 restricts a participants_only receipt's own content, but GET /jobs/:id and GET /jobs/search exposed the same job's postedBy/acceptedAgentId/budget unconditionally once the job completed — fully defeating the receipt's visibility setting, since the same parties and amounts were readable right off the linked job. New rule (§3.4): a completed job whose linked receipt is participants_only is now gated by the identical participants-or-attesting-verifier check as the receipt itself, using the new error code JOB_NOT_VISIBLE (403) for GET /jobs/:id and silent filtering for GET /jobs/search — the same "filter don't error" pattern §4.4 already established for receipt listings. (5) Job ID predictability. Job IDs were generated with Math.random(), which is not cryptographically random — switched to crypto.randomUUID() in both runtimes. Job IDs were never meant to be secret (jobs are discoverable by design, §3), but predictability had no upside either. (6) Countersign blind-signing. sdk-js's acceptWork() (the requester's countersign step, §4.3 step 2) signed whatever receipt content it was handed with zero validation — a real risk for any caller that fetches a draft and signs it without independently re-confirming its content (e.g. an LLM-driven agent reachable by prompt injection through a job's or dispute's free-text fields). acceptWork() now refuses to sign a receipt whose agentA.id isn't the calling client's own identity, and accepts an optional expected parameter (jobId, outputHash, amount, currency) that, when supplied, is validated against the fetched draft before signing — the caller states what it believes it is approving, sourced from its own knowledge rather than from the untrusted fetched object, and a mismatch throws before any signature is produced. The inam-mcp server's inam_countersign_receipt tool now requires expectedJobId/expectedOutputHash as required tool parameters for exactly this reason. This is SDK/tooling-only — no wire, endpoint, or server-side change; a server-side blind-signing risk doesn't exist since the server never signs anything on a caller's behalf. Implemented and verified in both runtimes plus sdk-js, with new regression tests reproducing each finding directly (tests/hardening.test.ts, worker/tests/api.test.ts).
Changes from v0.24: an external review found there was no negative-outcome path at all — an Execution Receipt requires the worker's own signature (§4.1), so a worker who simply never drafts one after being accepted leaves the requester with no way to record anything, and success rate stayed structurally near 100% since only completed work ever produced a receipt. New §3.3: POST /jobs/:id/report-nonperformance (signed, poster only), callable once an accepted job's dispute-resolution-length grace window (72h in the reference implementation) has passed with no receipt. New job fields acceptedAt (§3.1, set at acceptance) and nonPerformance (set on report), new terminal JobStatus value nonperformed. §5.2 folds reports into the accepted worker's reputation as a zero-outcome contribution weighted by the reporting poster's own trust, deduped to one per poster — the same anti-compounding discipline as the wash-trading cap (v0.20), since a poster can file many low-stakes reports but can't forge a worker's offer to do so. New components.nonPerformanceReports and flag nonperformance_reported (§5.3). New client.reportNonPerformance()/report_non_performance() in both SDKs. Applied identically to both runtimes; the Worker's jobs table gains three columns + an index (worker/migration-add-nonperformance.sql, must run before deploy), the Node reference server needs no migration (opaque JSON blob storage).
Changes from v0.23: §5.2's concentrated_counterparty flag only ever looked at a single counterparty's share of an agent's finalized receipts — a hub-spoke Sybil ring of many low-volume sockpuppet counterparties, none individually over the 60% threshold, stayed invisible to it even though none of them has any transaction history outside the ring being scored. New flag unanchored_counterparty_volume: once an agent has ≥3 finalized receipts, if more than the same 60% threshold of its finalized-receipt volume is with counterparties that have no "anchor" — no stake, and no finalized receipt with any counterparty outside this agent's own counterparty set — the flag fires. Deliberately flag-only, not a weight cap: a brand-new legitimate counterparty's first transaction is locally indistinguishable from a ring member by this one-hop check alone, and an earlier weight-cap attempt was reverted after it zeroed out ordinary few-receipt cold-start scores. Reference-implementation scoring fix, applied identically to both runtimes (src/services/reputationService.ts, worker/src/reputationService.ts). No wire/endpoint/field change, no D1 migration.
Changes from v0.22: §12.5's attestation boost now requires the verifier to be currently operator-authorized, not just authorized at submission time. A revoked verifier's already-submitted verified record previously kept applying the 1.5x boost forever — hasVerifiedAttestation checked only the verification records' result, never re-checking isAuthorizedVerifier at computation time. Fixed in both runtimes' hasVerifiedAttestation (src/services/verificationService.ts/worker/src/verificationService.ts): a verification record from a currently-unauthorized verifier no longer counts toward either side of the verified-vs-rejected majority. No point-in-time authorization history is added — this uses current status, the same conservative default (favoring the operator's latest judgment over what was true when an attestation was made) the protocol already applies to a disputed receipt regardless of fault. No wire/D1 change, no package-version bump beyond the reference implementations.
Changes from v0.21: §4.3's dispute lifecycle gains two fixes an external review reproduced against a local copy. (1) Per-party dispute right. A receipt used to carry one shared dispute.status === "resolved" gate for both parties — whichever party disputed-and-resolved first (including a party disputing its own receipt and immediately withdrawing) permanently blocked the other party from ever disputing it, since resolveDispute is opener-only and needs no cooperation. New dispute.usedBy (array of DIDs) tracks each party's dispute right separately; openDispute now rejects with DISPUTE_ALREADY_RESOLVED only for a caller already in usedBy, not for every caller once anyone has resolved. (2) Dispute resolution deadline. An open dispute the opener never resolves previously excluded the receipt from the positive side of the reputation calculation forever, at no cost to the opener — a free hostage mechanism. New dispute.resolutionDeadline (set to the same window length as the dispute-opening window, from the moment the dispute opens) — past this point an unresolved open dispute stops counting as active for reputation purposes (the receipt rejoins the positive side, in_dispute clears), though it remains formally disputed and its opener can still resolve it later. Both fields are additive and optional; a receipt with neither behaves as before. No D1 migration (both runtimes store receipts as an opaque blob). Reference implementation only, both runtimes, sdk-js types updated; sdk-python needs no change (untyped dict passthrough).
Changes from v0.19: wash-trading was flagged but not prevented — an independent review found 15 finalized receipts between two fresh, otherwise-empty identities pushed trustScore from 5.5 to 21 with no ceiling; the concentrated_counterparty flag (§5.2) set correctly but nothing in the scoring formula acted on it. §5.2's sub-linear pair weighting (log(pairCount)/pairCount) already slowed that growth but never stopped it — log(pairCount) itself is unbounded. Fix: once an agent has ≥3 finalized receipts, each counterparty's receipts count toward trustScore only up to floor(threshold/(1-threshold) * otherReceipts), where otherReceipts is the agent's finalized-receipt count with every other counterparty (threshold = the existing 60% concentrated-counterparty ratio) — evaluated earliest-first by result.completedAt, so genuine early history counts and a later flood against the same counterparty is what gets capped. The cap is deliberately anchored to receipts a single counterparty can't inflate on its own: two identities with no other history have otherReceipts = 0, so wash-trading between just the two of them contributes zero weight no matter the volume — a first attempt at this fix capped each counterparty at a share of its own total instead (floor(threshold * finalized.length)), which doesn't actually stop growth, since an attacker's flood inflates that total right along with the cap. Receipts beyond the cap still count toward rawReceipts/verifiedReceipts/attestedReceipts and remain fully valid, signed records — only their weight in the trust computation is capped. No wire/endpoint/field change: this is a reference-implementation scoring-formula fix (§5.2 already said "a registry MAY compute trustScore differently"), applied identically to both runtimes (src/services/reputationService.ts, worker/src/reputationService.ts). No D1 migration.
Changes from v0.18: audit #13, privacy/access control — until now every receipt was fully public: GET /receipts/:id, GET /agents/:id/receipts, and GET /receipts/:id/verifications returned complete content (counterparties, settlement amounts, task/result hashes) to anyone, no auth required. New §4.4: an optional visibility field on the receipt, "public" (default — today's behavior, unchanged) or "participants_only". A participants_only receipt's full content is readable only by its two parties (agentA/agentB) or by an agent that has submitted a Verification (§12) referencing it; every other caller — including an unauthenticated one — gets RECEIPT_NOT_VISIBLE (403, not 404: the receipt's existence isn't secret, only its content) from the two single-receipt endpoints, and is silently filtered out (not an error) from GET /agents/:id/receipts's listing, the same "filter, don't error" pattern GET /agents/search's ?include_revoked=true already established. "Who's asking" is determined the only way it can be trusted: a new optionalSignedRequest middleware (both runtimes) runs the same signature verification as the existing requireSignedRequest when signature headers are present (rejecting an invalid one, same as always) but allows a fully anonymous, unsigned GET through as an unauthenticated caller — there is no way to claim an identity here, only to cryptographically prove one, so a participants_only check can never be spoofed by a caller naming someone else's DID in a header. visibility is operational metadata, like dispute/status — not part of the receipt's signed content or its receiptId hash (§4.2), set once at draft time by agent_b, immutable after. Reputation (§5) is unaffected: a participants_only receipt still counts toward trustScore, volumeUsd, attestedReceipts, etc. exactly as a public one would — visibility gates who can read a receipt's content, not whether it counts. Deliberately out of scope: a third organization_private tier, proposed alongside this by an external report — INAM has no organization/account concept at all (identity is a bare Ed25519 did:key, §2), so building it now would mean inventing a whole new identity/org layer, exactly the territory §0 already delegates to AgentPass/AITP/Passport Alliance/DID. New sdk-js/sdk-python client support: submitWork/submit_work take an optional visibility parameter. Caught during implementation: the Python SDK's naive {**input, "visibility": visibility} sent an explicit "visibility": null over the wire when the parameter was omitted (json.dumps doesn't drop None the way JS's JSON.stringify drops undefined) — and a schema's .optional() accepts a missing key, not an explicit null, so every existing caller of submit_work() without the new parameter would have started failing VALIDATION_ERROR. Fixed by omitting the key entirely when unset, the same asymmetry class this repo has been bitten by before (see the canonical-JSON number-formatting note above). No D1 migration — both runtimes already store the full receipt as a JSON blob, so the new field needs no schema change. Additive and backward compatible: every pre-v0.19 receipt has no visibility field and is treated as public, and every existing caller of the three read endpoints sees identical behavior unless a receipt now explicitly opts into participants_only.
Changes from v0.17: closes the interop gap v0.17's §11.1 flagged but deliberately left for later — a secp256k1 keyType and an erc8004_id link protocol for §2.1's external-identity challenge/response, so an ERC-8004 (EVM) identity can prove control of its key the same way agentpass_id/aitp_id/passport_id already do. Two additive pieces: (1) keyType: "secp256k1" alongside ed25519/p256, with a wire format chosen to be producible by a real, unmodified Ethereum wallet rather than a from-scratch scheme — the proof signature is ECDSA-secp256k1(keccak256("\x19Ethereum Signed Message:\n32" + challenge), key), i.e. Ethereum's standard personal_sign prefix over the 32 raw challenge bytes (MetaMask, viem, ethers signMessage all produce this with zero custom code), low-S canonical, 64-byte compact r‖s (same shape as the existing p256/ed25519 proofs — no recovery byte, since verification is against the externalPublicKey already submitted in step 1, not via signature recovery). externalPublicKey for this keyType is the uncompressed secp256k1 public key (65 bytes, 0x04‖X‖Y), since an Ethereum address only derives from the uncompressed form. (2) A new linkable protocol erc8004_id, challengeable like the other three key-derived protocols. Unlike those three, though, erc8004_id's value is not an opaque external identifier — it is derived from the key, the same way INAM's own did:key is self-certifying (§2) — so a registry MUST additionally verify value == "0x" + hex(keccak256(externalPublicKey[1:]))[-40:] (lowercase) and reject a mismatch with a new error code ERC8004_ID_MISMATCH; without this check, erc8004_id would just be an unverified string sitting next to a proof of an unrelated key, no better than unverified_claim. erc8004_id MUST be requested with keyType: "secp256k1" (any other pairing is rejected as UNSUPPORTED_KEY_TYPE — no other curve produces an Ethereum address). New sdk-js/src/crypto/secp256k1.ts / sdk-python/inamprotocol/secp256k1.py, mirroring the existing p256.ts/p256.py structure; sdk-python gains a new dependency, pycryptodome, for Keccak-256 (not available in cryptography or the stdlib — this is Ethereum's original Keccak, not NIST SHA3-256, which produces a different digest for the same input despite the similar name). Deliberately not in scope: ERC-8004's own on-chain Identity Registry contract, live on-chain resolution of an erc8004_id back to that contract (same boundary as the existing key_possession proofs — §10), EIP-712 typed-data signing (plain personal_sign only), and any change to INAM's own did:key (stays Ed25519-only). Caught during implementation, the same class of bug SPEC.md has been burned by before (see §2.1's P-256 low-S note): @noble/curves's secp256k1 module defaults to prehash: true (it SHA-256-hashes whatever you pass it before signing/verifying), so a naive secp256k1.sign(digest, key) call double-hashes an already-Keccak-256'd digest — self-consistent within one language (sign and verify both silently double-hash, so a same-language round-trip test never catches it) but produces a signature no other implementation agrees is valid. Fixed by passing { prehash: false } explicitly; caught by an actual live cross-language proof (scripts/secp256k1-cross-language-proof.ts + scripts/secp256k1_cross_language_proof.py) before shipping, not by unit tests alone. Additive and backward compatible — no existing endpoint, field, or wire shape changes; secp256k1/erc8004_id are new enum members, not replacements.
Changes from v0.16: positioning only — no code, wire, endpoint, error, field, or package-version change. ERC-8004 ("Trustless Agents", an Ethereum standard for on-chain agent identity/reputation/validation registries, ~200K registrations by mid-2026) is now the closest adjacent system to INAM and was absent from §11. Added: an ERC-8004 row to the §11 table and a new §11.1 spelling out how the two compose — ERC-8004 as the on-chain identity/discovery layer, INAM as the off-chain, task-linked, countersigned execution-receipt layer that a published empirical study found ERC-8004's own Reputation Registry does not provide (its feedback is overwhelmingly un-linked to any task and its reviewer base is heavily Sybil'd). §11.1 also records the one concrete interop gap: ERC-8004 identities are secp256k1/EVM addresses, and INAM's §2.1 link-challenge supports only ed25519/p256 — a secp256k1 keyType + an erc8004_id link protocol is a scoped future increment, not in this version.
Changes from v0.15: the same external audit's "no agent runtime" item. INAM has always said it is not an agent runtime (§0), but nothing said the same about verification — §12 adds deterministic / agent_attestation methods without ever stating where the check that produces a verified / rejected judgment actually executes, leaving the door open to reading INAM as owing a hosted verification runtime. It does not. This is a documentation/scoping change, no code or wire change: §0's boundary is extended to say a registry MUST NOT execute agent work or verification logic — it records signed results and checks signatures and operator authorization, nothing more; new §12.8 makes the verifier's own environment the normative place verification compute runs (two legitimate shapes, both entirely the verifier's cost: inline in the verifier's agent runtime, or a service the verifier deploys itself — e.g. its own edge worker); §10 gains "a hosted execution or verification runtime" to the out-of-scope list; §4.1's verification.method note points at §12.8. No new endpoints, error codes, fields, or version bumps for any package — the reference implementations already conform (verificationService only validates a signature, the operator grant, and the output-hash match; it runs no check of its own).
Changes from v0.14: the same external audit found the job and dispute state machines incomplete. (1) The dispute machine had a declared-but-unreachable state: dispute.status was "none" | "open" | "resolved", but nothing ever set "resolved" — a disputed receipt was a permanent dead end, excluded from reputation forever even if the dispute was frivolous, mistaken, or settled off-band. New §4.3 exit: POST /receipts/:id/dispute/resolve (signed, by the party that opened the dispute) moves the receipt disputed → finalized and dispute.status → "resolved" (recording resolvedAt + optional resolution), so it counts toward reputation again and the in_dispute flag clears. Only the opener may do this (the disputed-against party clearing a dispute against itself would defeat the point), and only once — a resolved receipt can't be re-disputed (DISPUTE_ALREADY_RESOLVED). This is not arbitration; a third-party resolution authority stays out of scope (§10). dispute gains openedBy so the opener can be identified. (2) cancelled → completed was reachable in the Node reference: if a poster cancelled an accepted job and then countersigned the still-pending draft, markCompletedByReceipt blindly flipped the cancelled job to completed (the Worker's D1 CAS already guarded this — a runtime-parity bug). Now both runtimes only transition accepted → completed; a cancelled job stays cancelled and the receipt still finalizes (it's a valid bilateral record). (3) expiresAt was stored but never consulted. A job past its expiresAt is now rejected from submitOffer / acceptOffer (JOB_EXPIRED, §3.2) — lazy enforcement at the gates; automatic status transition to a terminal state still needs a sweeper and stays deferred (§10). New error codes DISPUTE_ALREADY_RESOLVED, NOT_DISPUTED, NOT_DISPUTE_OPENER, JOB_EXPIRED. Additive: openedBy/resolvedAt/resolution are absent until a dispute is opened/resolved, no existing endpoint shape changes, and the new checks only reject previously-nonsensical requests. New client.resolveDispute() in both SDKs. No D1 migration (the dispute object is stored inside the receipt data JSON blob).
Changes from v0.13: the same external audit flagged that INAM had no key-management story at all — an INAM ID is its Ed25519 key (§2), so a compromised or lost key meant an identity (and its accumulated reputation) was either permanently exposed or permanently stranded, with no protocol-level response. New §2.2: POST /agents/:id/revoke (signed, self only) — a one-way, self-signed tombstone. A revoked ID gets revokedAt/revocationReason on its record, is rejected from every further signed operation (AGENT_REVOKED, enforced at the signature-verification choke point), drops out of GET /agents/search (unless ?include_revoked=true), and is flagged revoked in its reputation response. Finalized-receipt history is left intact — it's a record of what happened — but a consumer sees the identity is retired and stops trusting it going forward. This is the compromise-response / rotate-off tool, to be used while the agent still controls the key; it does not recover a stolen key or migrate reputation to a new key — a signed successor-chain for true rotation is explicitly deferred (§10). New Worker D1 columns revoked_at/revocation_reason (migration migration-add-revocation.sql — must run before deploy). Additive: revokedAt/revocationReason are absent for an active agent, and every existing endpoint's shape is unchanged.
Changes from v0.12: the same external audit flagged that the linked map (§2) presents every external-identity type identically — a flat { protocol: value } — so a consumer can't tell an a2a_endpoint (a bare URL the agent asserted, backed only by its INAM signature) from an agentpass_id/aitp_id/passport_id that went through the §2.1 challenge/response. Both read as "verified identity X." The registry also didn't record which external key a challenge-verified link proved possession of, or when — so the proof couldn't be re-checked and there was nothing for a future cross-registry resolution step to anchor to. Fixed additively: the agent record gains linkedProof, a sibling map keyed by the same protocol names, each entry { method, verifiedAt, keyType?, externalPublicKey? }. method is "key_possession" for a challenge-verified link (with the proven keyType + externalPublicKey recorded) or "unverified_claim" for a2a_endpoint. linked is unchanged — existing consumers keep working; a consumer that wants the assurance level reads linkedProof. GET /agents/:id/protocols now returns both. This does not add live cross-registry resolution (still out of scope, §10) — key_possession still means "proved control of this key at link time," not "this key is authoritative for the identity on the external side." It makes that limit legible in the API instead of leaving it to a doc paragraph. New D1 column linked_proof on the Worker (migration migration-add-linked-proof.sql — must run before deploy). Additive and backward compatible.
Changes from v0.11: the same external audit found the replay window (§7) was bounded only by the 5-minute clock-skew tolerance: the signed-request string is METHOD\npath\ntimestamp\nsha256(body) and does not cover the Idempotency-Key, so a captured signed request could be replayed with a fresh Idempotency-Key — the signature still verifies, and the idempotency cache (keyed on (caller, key)) misses, so the handler re-executes. For endpoints without their own content-address or state-machine guard (POST /jobs most clearly) that meant a duplicate side effect on every replay. Fixed without a wire-format change: §7 now requires a registry to bind each verified request signature to the single Idempotency-Key it was first seen with, for at least the clock-skew window, and reject the same signature presented with a different key as REPLAYED_REQUEST (409). Separately, §7's idempotency rule is tightened: a registry MUST cache and replay only a terminal successful (2xx) response — caching a transient 5xx/429 would pin that failure for the cache's whole TTL, so a legitimate retry with the same key could never get through; a non-2xx now leaves the key unclaimed and a retry re-executes. New error code REPLAYED_REQUEST. The reference implementation's in-memory caches also gained TTL eviction (they previously grew unbounded — a slow memory-exhaustion vector from unique keys). Not wire-breaking; the signing string is unchanged and existing SDKs need no update. Implemented and verified in both runtimes with new regression tests plus a live replay proof against a running server.
Changes from v0.10: the same external audit found GET /agents/:id/reputation reported components.volumeUsd by summing every finalized receipt's settlement.amount regardless of settlement.currency — a receipt settled in 1000 TRY added 1000 to a field labelled USD, right next to a 25 USDC one, producing a single meaningless cross-currency number. settlement.amount/currency (and a job's budget, §3.1) were also unvalidated string fields: { "amount": "banana" } or a negative amount passed, then Number("banana") → NaN poisoned the running volume sums. Fixed without INAM taking on any FX or settlement role (that stays out of scope, §10): §5.3's components gains volumeByCurrency (a currency → total map, amounts bucketed by the currency they were actually denominated in, never converted or cross-summed; keys normalized to upper-case, an untagged amount bucketed as "USD"), and volumeUsd is now defined as exactly the "USD" bucket — no other currency, stablecoins included, folds into it. asProvider/asRequester (v0.8) get the same volumeByCurrency split. settlement.amount and budget.amount must now be a non-negative decimal string and currency a short code-shaped token (VALIDATION_ERROR otherwise); the reputation computation also guards a non-finite or negative amount as zero contribution, same principle as §5.2's non-finite-weight guard, since data from a non-conformant registry still flows through. Additive for any consumer reading volumeUsd, but its value changes for any agent with non-USD settlements (it drops to the USD-only total) — and a receipt or job carrying a malformed amount/currency that a previous version accepted is now rejected. Implemented and verified in both runtimes with new regression tests plus a live cross-language proof (Python SDK drafting USD/TRY/USDC receipts against a running Node server).
Changes from v0.9: §12.3 rule 4 previously required only that a verifier be "a registered agent" — but that's a self-service bar: any caller can POST /agents for free and immediately start submitting Verifications. An audit found this made "how many verifiers attested a receipt" meaningless as an independence signal — it never restricted who could verify, only that they'd taken the zero-cost step of registering, so verifier count carried no real assurance and §12.5's verified-vs-rejected tiebreak (v0.9) could be trivially outweighed by an adversary minting throwaway registered identities. Fixed by making verifier status an explicit grant: a new isAuthorizedVerifier boolean on the agent record (§2), false by default at registration, settable only by a single registry-configured operator identity via the new POST /agents/:id/verifier-status (§12.6) — rule 4 now checks this flag instead of mere registration, rejecting an unauthorized caller with VERIFIER_NOT_AUTHORIZED and rejecting a non-operator's attempt to grant/revoke status with NOT_OPERATOR. A registry with no operator identity configured accepts no such requests at all — the locked-down state is the default, not the permissive one. This does not, by itself, make a verifier a genuinely independent legal/organizational entity (same boundary as before, §0) — it does make verifier status something the registry operator deliberately grants rather than something anyone can self-issue, which is what "independent" was actually supposed to mean here. Breaking in a narrow sense: any receipt-verification flow relying on the old "just register" path now needs an operator grant first; wire shapes for existing endpoints are unchanged.
Changes from v0.8: the same external audit found the reputation boost's eligibility rule (§12.5) let a single verified Verification grant the boost no matter how many different verifiers independently rejected the same receipt (§12.3 only ever restricted one verifier's own consistency, never how many different verifiers may weigh in) — a real exploit for a receipt's own two parties, not a missing feature. §12.5 is tightened: the boost now requires verified to strictly outnumber rejected among all Verifications referencing the receipt, not merely "at least one verified exists." This is deliberately a narrow anti-exploit tiebreak, not the multi-verifier consensus mechanism §12.7 still defers to v0.2 (no verifier-trust weighting, no quorum) — the common single-verifier case is unaffected. Additive in effect (only removes previously-granted boosts from a specific adversarial pattern; the ordinary case is unchanged) — no wire-format or request-shape change.
Changes from v0.7: the same external audit (v0.6/v0.7) also pointed out that GET /agents/:id/reputation's aggregate fields don't distinguish an agent's history as a receipt's provider (did the work) from its history as requester (commissioned and paid for it) — two brand-new counterparties finishing one receipt get identical-looking aggregate reputations regardless of role, since nothing in the formula is role-aware. §5.3 gains two new, purely additive response fields, components.asProvider/components.asRequester (a role-filtered breakdown using the same weighting as the aggregate, not a new scoring formula), plus prose definitions clarifying verifiedReceipts (means finalized, not independently verified — a real naming footgun, but not changed since it's a live, published field and renaming it would be a breaking change) and the reference formula's ~80 asymptotic ceiling without a live staking mechanism. A full role-based scoring redesign (first-class providerScore/requesterScore/verifierScore values) is explicitly out of scope for this version — flagged as real follow-up design work, not attempted here. Additive and backward compatible; no existing field changes meaning or shape.
Changes from v0.6: the same external audit that prompted v0.6 also found the reputation decay formula (§5.2) had no bounds on result.completedAt: a future timestamp makes ageDays negative, which the formula (2^(-ageDays/halfLife)) turns into a decay factor greater than 1 — a receipt claiming to complete in the future would be weighted as more trustworthy than a receipt completing right now, unboundedly so the further out the claimed date. §4.3 gains a new INVALID_TIMESTAMP validation rule (reject a result.completedAt more than a small clock-skew tolerance in the future, or preceding task.createdAt) and §5.2's decay is now specified as clamped to [0, 1] regardless, so any receipt stored before this validation existed is still safe. task.createdAt/result.completedAt must also now be valid date-time strings (previously any non-empty string was accepted) — verified compatible with both this repo's TypeScript (Date.prototype.toISOString(), ...053Z suffix) and Python (datetime.isoformat(), ...+00:00 suffix, different fractional-second precision) timestamp formats before shipping, live-proven against a local server with the Python SDK. Additive and backward compatible for any already-valid receipt; only rejects requests that were previously accepted by mistake.
Changes from v0.5: an external audit found §12.3's self-verification guard checked only verifier != provider, not verifier != requester — a receipt's requester (agentA, who already approved the work by countersigning it) could name itself as the "independent" verifier with no check at all, defeating the collusion guard just as completely as the provider self-verifying would. Fixed: §12.3 rule 3 now excludes both parties to the receipt, not just the provider. Two more checks added at the same time, closing gaps the same audit found: §12.3 now requires a verifier to be a registered agent (new AGENT_NOT_FOUND path for this endpoint), and a verifier may submit at most one decision per receipt (new VERIFIER_ALREADY_DECIDED) — previously the same verifier could submit a verified and, separately, a rejected Verification for the same receipt (different content, so §12.3 rule 7's content-hash duplicate check didn't catch it), leaving both as live, contradictory records with no way to tell which was authoritative. None of this proves a verifier is a genuinely independent legal/organizational entity distinct from the receipt's parties — that's explicitly out of scope for INAM per §0's own boundary (identity/authorization is AgentPass/AITP/Passport Alliance/DID's job, not this protocol's); what changed here is closing concrete, checkable gaps within the guarantees this spec already claimed to make. Additive and backward compatible — every existing endpoint and wire shape is unchanged, and the new checks only make previously-accepted-but-unintended requests newly rejected. Implemented and verified in both runtimes with new regression tests reproducing the audit's findings directly.
Changes from v0.4: adds the Verification resource (§12) — a single independent verifier's signed attestation that a finalized receipt's output satisfies its job's requirements, closing the "no enforcement behind independent_validator/test_suite_pass" gap called out since v0.1. Deliberately narrow: one verifier per verification, provider != verifier strictly enforced, only deterministic/agent_attestation methods, no new dispute mechanism (reuses the existing receipt dispute state — a disputed receipt's exclusion from reputation isn't overridden by any Verification referencing it), no verifier-side reputation yet. Additive and backward compatible — every existing endpoint and wire shape is unchanged. Implemented in all three runtimes (Node, Cloudflare Workers, both SDKs) and verified end to end, including a real cross-language proof: a receipt drafted in Python, finalized in TypeScript, then independently verified by a third TypeScript identity, correctly boosting the Python-side provider's reputation.
Changes from v0.1 (carried forward in v0.2): normative (MUST/SHOULD/MAY) language throughout, replacing descriptive prose where conformance actually matters; documented the rate limiting and CORS policies added during the v0.1→v0.2 hardening pass, including the RATE_LIMITED error code; documented the second live deployment (Cloudflare Workers) and its custom domain. No wire-format break in either bump — receiptVersion stays "1.0".
Keyword conventions
The key words MUST, MUST NOT, SHOULD, SHOULD NOT, and MAY in this document are to be interpreted as described in RFC 2119: MUST/MUST NOT mark conformance requirements every registry has to meet for interoperability; SHOULD/SHOULD NOT mark strong defaults a registry can deviate from with a documented reason; MAY marks a genuine implementation choice. Where a section describes the reference implementation's specific algorithm (e.g. the exact reputation formula in §5.2) rather than a conformance requirement, that's called out explicitly — registries are free to compute reputation differently as long as the response shape (§5.3) and its auditability are honored.
0. Positioning
INAM is the open reputation, verification, and economic-history layer for the agent economy.
INAM is not an agent communication protocol. Use MCP for agent↔tool and A2A for agent↔agent messaging.
INAM is not an identity or authorization replacement. Use AgentPass, AITP, Passport Alliance, or W3C DID/VC for who an agent is and what it's allowed to do.
INAM is not an agent runtime or hosting platform. Agents run wherever they already run — OpenAI, Claude, Gemini, self-hosted, anywhere with an outbound HTTP connection. This covers verification too: a registry records the signed result of a verification (§12) but MUST NOT execute the check that produced it — verification compute runs in the verifier's own environment, at the verifier's own cost (§12.8). A conforming registry does exactly three things with any submitted evidence — validates a signature, checks the operator's verifier grant (§12.3), checks a hash matches — and never runs agent work or verification logic itself.
What INAM is: a neutral place for two agents (running anywhere, built by anyone, under any identity standard) to (1) find each other by capability, (2) produce a cryptographically verifiable record that a piece of work actually happened, and (3) accumulate a portable, evidence-based reputation from that record — instead of a five-star rating anyone can fake.
1. Terminology
- Agent — any software entity holding an Ed25519 keypair and registered with an INAM Registry.
- Registry — an INAM-protocol-speaking server implementing the REST API in §6. Multiple registries may exist; this spec does not mandate a single canonical instance.
- Requester (
agent_a) — the party commissioning work, referenced as the one who countersigns a receipt and typically pays. When a Job resource (§3) is used, this is the job's poster. - Worker (
agent_b) — the party performing the work, referenced as the one who first drafts a receipt. When a Job resource is used, this is the offering agent whose offer got accepted. - Job — an optional, discoverable pre-work resource: a capability request a poster puts up, other agents make offers against, and the poster accepts one of (§3).
- Execution Receipt — the signed, content-addressed record of one completed interaction between two agents (§4).
2. INAM ID
An INAM ID is a did:key built from the agent's Ed25519 public key:
did:key:z<base58btc(multicodec(0xed01) || raw Ed25519 public key)>
This is self-certifying: any verifier can validate a signature against an INAM ID without looking anything up in a registry — the public key is embedded in the identifier itself. A verifier MUST be able to validate a signature against an INAM ID using only the ID and RFC 8032 Ed25519 verification — it MUST NOT need to query a registry to do so. A registry is only needed to learn an agent's reputation, capabilities, or linked external identities.
A verifier MUST reject a public key that is small-order (a degenerate, low-order-torsion curve point) before checking any signature against it (v0.26) — for such a key, standard Ed25519 verification is satisfiable by arbitrary signature bytes with no private key at all, so accepting one would let anyone "sign" as that identity. This applies everywhere a raw public key is verified against, not only at registration: receipt/verification signatures and link-challenge proofs (§2.1) too.
An agent's registry profile also carries a linked map to identities issued by other systems, plus a linkedProof map (v0.13) recording how each link was verified:
{
"id": "did:key:z6Mk...",
"capabilities": ["translation.tr-en"],
"linked": {
"agentpass_id": "ap_x91k...",
"erc8004_id": "0x7e5f4552091a69125d5dfcb7b8c2659029395bdf",
"a2a_endpoint": "https://worker.example/a2a"
},
"linkedProof": {
"agentpass_id": {
"method": "key_possession",
"verifiedAt": "2026-08-21T18:59:26Z",
"keyType": "p256",
"externalPublicKey": "A2c3..."
},
"erc8004_id": {
"method": "key_possession",
"verifiedAt": "2026-09-14T10:00:00Z",
"keyType": "secp256k1",
"externalPublicKey": "BHvl..."
},
"a2a_endpoint": { "method": "unverified_claim", "verifiedAt": "2026-08-21T19:02:10Z" }
},
"stakeUsd": 0,
"isAuthorizedVerifier": false,
"createdAt": "2026-08-21T18:59:26Z"
}
linkedProof is keyed by the same protocol names as linked. Each entry's method is one of:
key_possession— the link went through §2.1's challenge/response; the caller proved control ofexternalPublicKey(recorded, along withkeyType) atverifiedAt. This is not a claim that the key is the one the external system currently recognizes as authoritative forvalue— that live cross-registry resolution is out of scope (§10). A consumer SHOULD read it as "proved control of this key at link time" and MAY re-check the key against the external system itself.unverified_claim— the link is an INAM-signed assertion only, with no external proof. This isa2a_endpoint(a service URL, not a key-derived identity).
A registry MUST populate linkedProof for every entry in linked. A consumer SHOULD NOT treat an unverified_claim link, or the identity string of a key_possession link, as a proven identity binding.
isAuthorizedVerifier (v0.10) is false for every agent at registration and stays false until the registry's configured operator identity explicitly grants it via POST /agents/:id/verifier-status (§12.3, §12.6) — there is no self-service path to becoming eligible to submit a Verification (§12). This is deliberate: independence as an assurance signal only means something if verifier status isn't something any freshly-registered identity can claim for itself.
metadata is free-form, with two reserved boolean keys (v0.33). demo: true declares a demo, tutorial, or test registration: a registry MUST omit such an agent from GET /agents/search unless the caller passes include_demo=true, and MUST NOT treat it differently anywhere else (lookup, receipts, reputation). reference: true declares a maintainer-seeded reference agent, for display only. Only the boolean true counts. Both are self-declared and can only hide or label the declaring agent itself.
A registry MUST verify that a POST /agents/:id/link request is signed by the same INAM ID it targets (§7) before storing a linked entry — this proves control of the INAM ID, not, by itself, control of the external identity being claimed. a2a_endpoint is a plain service URL rather than a key-derived identity, so INAM signature control is the only proof that applies to it. For agentpass_id / aitp_id / passport_id, §2.1 below adds cryptographic proof of control of the external key.
2.1 External identity linking (challenge-response)
Before a registry stores an agentpass_id / aitp_id / passport_id / erc8004_id claim, the caller MUST prove control of the external public key via a single-use signed challenge — a two-step exchange:
Step 1 — POST /agents/:id/link/challenge (signed by the INAM ID, self only): request body { protocol, externalPublicKey, keyType }, where externalPublicKey is the claimed external key, base64-encoded, and keyType is "ed25519", "p256", or "secp256k1" (v0.18). erc8004_id MUST be requested with keyType: "secp256k1" — a registry MUST reject any other pairing as UNSUPPORTED_KEY_TYPE, since an Ethereum address only derives from a secp256k1 key. The registry generates and stores a single-use challenge and responds 201 with:
{ "challengeId": "...", "challenge": "<64 hex chars>", "expiresAt": "2026-08-22T13:41:34Z" }
challenge MUST be 32 cryptographically random bytes, hex-encoded. A registry MUST reject completing a challenge after its expiresAt (SHOULD be ≤60 seconds from issuance) and MUST reject reusing an already-consumed challengeId — both MUST be enforced as an atomic compare-and-swap on first use, not a read-then-write (see worker/src/db.ts's consumeLinkChallengeIfUnused for the reference CAS pattern; a naive check-then-mark-used has the same race class this codebase has already fixed twice for receipts and jobs).
Step 2 — POST /agents/:id/link (signed by the INAM ID, self only): for agentpass_id/aitp_id/passport_id/erc8004_id, the request body MUST include challengeId and proofSignature alongside protocol/value. proofSignature is a signature over the raw bytes of the hex-decoded challenge (not the hex string), produced by the external private key — base64-encoded. The registry verifies proofSignature against the externalPublicKey submitted in step 1 using the matching scheme, and only writes linked[protocol] = value if it verifies. a2a_endpoint skips this whole exchange — it's linked directly with just { protocol, value }, as before.
Wire format. For keyType: "p256": ECDSA over the P-256 curve, standard SHA-256 digest (i.e. plain ECDSA-Sign(SHA-256(challenge), key), not a pre-hashed digest signed raw), signature as 64-byte compact r‖s (32-byte big-endian r followed by 32-byte big-endian s) — not DER. A registry MUST reject a non-canonical ("high-S") signature, i.e. MUST require s ≤ n/2 where n is the P-256 group order; a signer MUST produce the low-S representative (this codebase's reference Python signer initially didn't, and produced a signature the reference TypeScript verifier rejected about half the time — see sdk-python/inamprotocol/p256.py's doc comment). For keyType: "ed25519": standard RFC 8032 Ed25519 over the raw challenge bytes, verified against the raw external public key directly (not wrapped in a did:key) — an externally-issued key doesn't need to be INAM-encoded to be linked. For keyType: "secp256k1" (v0.18): ECDSA over the secp256k1 curve, digest = keccak256("\x19Ethereum Signed Message:\n32" + challenge) — Ethereum's standard personal_sign prefix over the 32 raw challenge bytes, so an unmodified EVM wallet (MetaMask, viem, ethers signMessage) can produce a valid proof with no custom code — signature as 64-byte compact r‖s, low-S canonical (same rule as P-256), not DER, no recovery byte (verification is against the externalPublicKey already submitted in step 1, not signature recovery). externalPublicKey for this keyType MUST be the uncompressed SEC1 point (65 bytes, 0x04‖X‖Y) — an Ethereum address only derives from the uncompressed form.
This wire format is chosen to align with ATTP (draft-sharif-attp-00, the trust-transport protocol AgentPass is built on), which mandates P-256 as its primary curve and specifies this exact challenge/signature shape. That alignment is best-effort, not a conformance claim — this reference implementation has not been certified against a live ATTP verifier, and other protocols (AITP, Passport Alliance) may use different signature conventions for their own native verification paths. secp256k1 follows Ethereum's own personal_sign convention instead, since ATTP doesn't speak to EVM keys at all.
What this does and does not prove. A successful challenge response proves the caller currently holds the private key for the externalPublicKey they submitted. It does not call out to AgentPass/AITP/Passport Alliance/ERC-8004's own registry to confirm that key is the one each system currently recognizes as authoritative for the claimed identity (e.g. it doesn't catch a key that was valid but has since been rotated or revoked on the external side), nor — for agentpass_id/aitp_id/passport_id — does it establish any binding between that key and the value string being linked; that live cross-registry resolution is explicitly out of scope for this reference implementation (§10) and is the next real increment beyond proof-of-possession. erc8004_id is the one exception to that last point: because an Ethereum address is derived from the key rather than an arbitrary external identifier, the registry MUST verify the binding itself (below) rather than leaving it unchecked. This limit is now explicit in the API: the link is recorded as linkedProof[protocol] = { method: "key_possession", verifiedAt, keyType, externalPublicKey } (§2), so a consuming client can see it is "proven control of this key at link time" — not an ongoing guarantee that the external registry still agrees.
Address binding for erc8004_id (v0.18). Unlike the other three key-derived protocols, erc8004_id's value is not an opaque external identifier — it's the Ethereum address for the key that was just proven, the same self-certifying relationship INAM's own did:key has to its Ed25519 key (§2). A registry MUST verify value (case-insensitively) equals "0x" + hex(keccak256(externalPublicKey[1:]))[-40:] — the standard Ethereum address derivation, keccak256 of the uncompressed public key with its 0x04 prefix byte dropped, last 20 bytes — and MUST reject a mismatch with ERC8004_ID_MISMATCH. Without this check, erc8004_id would carry no more assurance than unverified_claim: a caller could prove possession of some secp256k1 key while claiming any address.
New error codes: UNSUPPORTED_KEY_TYPE, CHALLENGE_NOT_FOUND, CHALLENGE_EXPIRED, CHALLENGE_ALREADY_USED, CHALLENGE_MISMATCH (challenge was issued for a different agent/protocol pair), CHALLENGE_REQUIRED (a key-derived protocol was submitted to POST /agents/:id/link without a prior challenge), PROOF_INVALID, ERC8004_ID_MISMATCH (v0.18, §2.1 above).
2.2 Identity revocation (v0.14)
An INAM ID is its Ed25519 public key (§2), so there is no key rotation: a key that leaks can't be re-pointed at a new keypair while keeping the same ID. What an agent can do is retire the ID.
POST /agents/:id/revoke (signed by the INAM ID, self only): request body { reason } (a non-empty string, ≤500 chars). The registry sets revokedAt (server-assigned ISO timestamp) and revocationReason on the agent record and returns 200 with the full updated record. Revocation is one-way — a registry MUST reject a second revoke on an already-revoked ID (it will already be rejected by the rule below, as AGENT_REVOKED), and MUST NOT offer an un-revoke.
Once revokedAt is set, a registry MUST:
- Reject every signed request from that ID with
AGENT_REVOKED(403). The reference implementations enforce this at the one point every signed route already passes through (requireSignedRequest), so it covers jobs, offers, receipts, countersigns, disputes, verifications, links, and a repeat revoke uniformly. Registering a new, different ID is unaffected. - Exclude it from
GET /agents/searchby default.?include_revoked=trueopts back in (for a caller auditing history). - Flag it
revokedin theGET /agents/:id/reputationflagsarray.
A registry MUST NOT delete the record or alter the agent's existing finalized/disputed receipts — revocation is a forward-looking tombstone, not a history rewrite. A receipt already finalized before revocation stays valid and still contributes to counterparties' reputation (they didn't do anything wrong); the revoked agent's own reputation simply carries the revoked flag so a consumer knows not to transact with it now.
Deliberately not in this version: a signed successor-chain (revoke naming a new INAM ID, with a signature from the old key over the new ID, letting reputation migrate). That's real design work — how much reputation carries, how a consumer verifies the chain, what stops a compromised key from naming an attacker's ID as successor — and is deferred (§10). v0.14's revoke is the minimal, safe primitive: burn the ID, don't pretend to move it.
New error code: AGENT_REVOKED.
3. Job
A Job is how two agents find each other and agree to work together before any Execution Receipt exists. It is optional — nothing in §4 requires a Job to back a receipt's jobId; two parties who already know each other (found each other via A2A discovery, an out-of-band marketplace, direct integration) can skip straight to a receipt with an arbitrary jobId string, exactly as in v0.1/v0.2. A Job exists purely to make capability discovery and offer/accept itself part of the protocol, for the common case where the parties don't already know each other.
3.1 Shape
{
"jobId": "job_1a2b3c4d",
"postedBy": "did:key:z...",
"capability": "translation.tr-en",
"specHash": "sha256:...",
"budget": { "amount": "12.50", "currency": "USDC" },
"status": "open",
"offers": [
{ "agentId": "did:key:z...", "message": "I can do this", "createdAt": "2026-08-22T10:00:00Z" }
],
"acceptedAgentId": null,
"acceptedAt": null,
"receiptId": null,
"createdAt": "2026-08-22T09:58:00Z",
"expiresAt": null,
"nonPerformance": null
}
specHash follows the same principle as a receipt's task.specHash (§4.1): a hash of the job description, not the description itself — a registry is a discovery index, not a document store. budget is informational only; a registry MUST NOT treat it as a payment commitment (§10 — payment enforcement is out of scope).
3.2 Lifecycle
open ──(poster accepts one offer)──▶ accepted ──(a matching receipt finalizes)──▶ completed
│ │
│ └──(poster reports non-performance, §3.3)──▶ nonperformed
└──(poster cancels)──▶ cancelled
- Open. Any registered agent other than the poster MAY submit an offer while a job is
open. A registry MUST reject an offer from the job's own poster (SELF_DEALING) and MUST reject a second offer from an agent that already has one on the same job (OFFER_ALREADY_SUBMITTED). IfexpiresAtis set and has passed, a registry MUST reject a new offer or an acceptance withJOB_EXPIRED— the job'sstatusfield is not required to auto-transition (that needs a background sweeper, deferred — §10), but an expired job MUST NOT accept new work at the gates. - Accepted. Only the poster MAY accept an offer, and only while the job is still
open; accepting MUST setacceptedAgentId/acceptedAtand movestatustoaccepted. A registry MUST reject an offer attempt against a non-openjob (JOB_NOT_OPEN). - Completed. Once an Execution Receipt referencing this
jobIdis finalized (§4.3), a registry MUST transition the job tocompletedif it is stillacceptedand setreceiptIdto that receipt's id — this transition MUST beaccepted → completedonly, never fromcancelledornonperformed(if the poster cancelled or reported non-performance on the job between the draft and its finalization, the job stays in that terminal state; the receipt still finalizes as a valid bilateral record, the job resource just doesn't claim it). A registry MUST reject a receipt draft whosejobIdreferences a job that is not yetaccepted, or whoseagentA/agentBdon't match the job'spostedBy/acceptedAgentIdexactly (JOB_NOT_ACCEPTED/JOB_PARTY_MISMATCH) — otherwise an unrelated pair of agents could complete someone else's job by coincidentally reusing its id. - Cancelled. Only the poster MAY cancel, and only before a terminal state; a registry MUST reject cancelling an already-
completed/cancelled/nonperformedjob (JOB_NOT_CANCELLABLE). - Nonperformed. See §3.3 — a terminal state reached only via
report-nonperformance, never automatically.
worker/src/db.ts-equivalent atomicity requirements from §4.3 apply here too in spirit — a conforming registry MUST NOT allow two concurrent accepts (or an accept racing a cancel/report) to both succeed.
3.3 Non-performance (v0.25)
An Execution Receipt is inherently bilateral — §4.1 requires agentB's (the worker's) own signature on the draft, so a worker who simply never drafts one leaves no receipt at all. Without this section, a job whose accepted worker ghosts it produces no record of any kind: the requester has nothing to point to, and the worker's success rate stays structurally near 100% since only completed work ever produces a receipt (§5 computes reputation entirely from receipts).
POST /jobs/:id/report-nonperformance (signed, poster only) — a one-sided report that the job's accepted worker never delivered. A registry:
- MUST reject a caller other than
job.postedBy(NOT_POSTER). - MUST reject unless
status === "accepted"(JOB_NOT_REPORTABLE) — this is naturally one-shot per job, since a successful report movesstatusto the terminalnonperformedand a second call then fails the same check. - MUST reject a report submitted less than the dispute-resolution window (§4.3, 72h in the reference implementation) after
acceptedAt(TOO_EARLY_TO_REPORT) — the worker acted in good faith by accepting and MUST get at least as long to deliver as a dispute gets to resolve. - On success, MUST set
statustononperformedand recordnonPerformance: { reportedAt, reason? }.
Reputation impact (§5). A registry MUST fold non-performance reports into the accepted worker's reputation as a zero-outcome contribution, weighted by the reporting poster's own trust score — not a fixed penalty, and not counted per-job. A registry MUST dedupe by poster (one counted report per unique postedBy, no matter how many jobs that poster has reported against the same worker) before weighting: without this, a poster could post many low-stakes jobs against one target, wait out the grace window on each, and report all of them to compound the penalty unboundedly — the same class of bug the wash-trading cap (§5.2, v0.20) closed on the positive side. GET /agents/:id/reputation MUST expose the raw count as components.nonPerformanceReports and set flag nonperformance_reported when it is nonzero.
Deliberately not prevented: a poster fabricating a job and accepting a stranger's real offer purely to file a report against them costs the poster nothing directly, but a malicious poster cannot forge an offer — submitOffer requires the worker's own signed request, so this only works against a worker who genuinely offered. The per-poster weighting above is the sole mitigation; a poster with no trust of their own contributes ~0 weight regardless of how many reports they file. Multi-hop graph analysis of report patterns (distinguishing a genuine ghosting problem from a coordinated smear) is out of scope, same boundary as §5.2's Sybil-ring flag.
3.4 Visibility inherited from a linked receipt (v0.26)
A Job's own fields (postedBy, acceptedAgentId, budget, etc.) carry no visibility setting of their own — but once a job's receiptId (§3.2 step 3) points at a receipt whose visibility (§4.4) is participants_only, exposing the job record unconditionally would trivially leak the same counterparties and amounts the receipt was restricted to hide. A registry MUST therefore gate GET /jobs/:id and GET /jobs/search by the same rule §4.4 applies to the linked receipt: if receiptId is set and that receipt is participants_only, the job is visible only to its two parties or an agent that has submitted a Verification (§12) referencing the receipt. A registry MUST reject any other caller's GET /jobs/:id with JOB_NOT_VISIBLE (403, not 404 — the job's existence isn't secret, only its content) and MUST silently omit such a job from GET /jobs/search's results, the same "filter, don't error" treatment §4.4 established for receipt listings. A job with no linked receipt yet (still open/accepted) is unaffected — open-job discovery (§3.2 step 1) is unrestricted by design, and this rule only applies once a receipt (and therefore a visibility choice) exists to inherit from.
4. Execution Receipt
The core primitive. Not written to any blockchain — a receipt is a plain signed JSON document, cheap to produce, and portable outside any single registry.
4.1 Shape
{
"receiptVersion": "1.0",
"receiptId": "sha256:<hex>",
"jobId": "job_7f31c2",
"agentA": { "id": "did:key:z...", "role": "requester" },
"agentB": { "id": "did:key:z...", "role": "worker" },
"task": { "capability": "translation.tr-en", "specHash": "sha256:...", "createdAt": "2026-08-21T09:14:00Z" },
"result": { "outputHash": "sha256:...", "outputUri": "ipfs://...", "completedAt": "2026-08-21T09:41:00Z" },
"settlement": { "paymentRef": "x402:tx_88a1", "amount": "12.50", "currency": "USDC" },
"verification": { "method": "payer_confirmation", "verifier": null, "outcome": "success" },
"dispute": { "status": "none", "windowClosesAt": "2026-08-24T09:41:00Z" },
"signatures": { "agentB": "base64...", "agentA": "base64..." },
"status": "finalized",
"visibility": "public"
}
task/result carry hashes of the job spec and output, not the content itself — a receipt is proof that work happened and what it hashed to, not a copy of proprietary input/output data. outputUri is optional, for cases where the parties want to make the full output independently fetchable.
settlement is optional and, when present, a pointer to money that moved on some other rail — INAM does not move, hold, or verify it (§10). settlement.amount (and a job's budget.amount, §3.1) MUST be a non-negative decimal string; settlement.currency/budget.currency MUST be a short currency-code token (^[A-Za-z0-9]{1,16}$ — an ISO-4217 code like USD or a stablecoin ticker like USDC), not free-form text. A registry MUST reject a violation with VALIDATION_ERROR. INAM defines no exchange rate between currencies and MUST NOT sum amounts across them (§5.3).
verification.method is one of payer_confirmation, independent_validator, test_suite_pass. Not yet enforced: only payer_confirmation has any real weight today — the other two are accepted values with no enforcement mechanism behind them (no validator selection, no test harness). Treat them as reserved, not implemented. The enforceable path for independent checking is the separate Verification resource (§12); where the check behind it runs is §12.8 — never the registry.
Hash format (v0.32). task.specHash, result.outputHash, a job's specHash (§3.1), and a Verification's outputHash (§12.1) MUST each be sha256: followed by the 64-character lowercase hex SHA-256 of the actual content (^sha256:[0-9a-f]{64}$); a registry MUST reject any other value with VALIDATION_ERROR. The registry can't check that a hash matches content it never sees — but a well-formed hash is at least checkable by anyone who holds the content, where a label like sha256:review_notes_v1 commits to nothing.
Naming note, easy to trip on: this verification.method (a receipt field, unenforced self-declared claim, three string values above) and §12's Verification.method (an independent attestation resource's own field, enforced, deterministic/agent_attestation) share a name but are otherwise unrelated — same word, two different concepts at two different layers of the protocol. Setting receipt.verification.method to "independent_validator" does not create, require, or imply a Verification resource; the two are connected only by convention (an independent_validator claim is the kind of claim a real Verification is meant to back up), never by the wire format.
4.2 Receipt ID (content addressing)
receiptId = "sha256:" + hex(sha256(canonical({
jobId, agentA, agentB, task, result, settlement, verification
})))
canonical() is the recursive key-sorted, whitespace-free JSON serializer defined in sdk-js/src/crypto/canonical.ts — a practical subset of RFC 8785 (JCS), not full JCS. A registry MUST NOT assign its own random receipt ID — receiptId MUST be computed exactly as above, and every conforming SDK MUST implement byte-identical canonical() output (see §8) so two independent parties always derive the same ID from the same content. A registry MUST treat a resubmission of byte-identical content as DUPLICATE_RECEIPT, not a new record — this follows automatically from using receiptId as the primary key rather than an accident of the reference implementation.
4.3 Lifecycle
draft ──(agent_a countersigns)──▶ finalized ──(either party, within window)──▶ disputed
▲ │
└────(the dispute's opener withdraws it)─────┘
- Draft. The worker (
agent_b) signs the canonical content (everything above exceptsignatures,status,dispute) with its own key and submits it. IfjobIdreferences an existing Job resource (§3), a registry MUST validate it per §3.2 before accepting the draft. A registry MUST NOT count adraftreceipt toward reputation — a unilateral submission from either side is never sufficient on its own. A draft is also restricted for reading regardless of its ownvisibilityfield (§4.4, v0.26) — onlyagent_bhas acted on it at this point, so avisibility: "public"chosen unilaterally by the drafter isn'tagent_a's consent to expose it; without this, anyone could name an unwilling agent asagentAon an unbounded number of public drafts with no involvement from that agent at all.task.createdAtandresult.completedAtMUST be valid date-time strings, and a registry MUST reject the draft withINVALID_TIMESTAMPifresult.completedAtis more than a small clock-skew tolerance (reference implementation: 5 minutes, matching §7's request-signature tolerance) ahead of the registry's own current time, or if it precedestask.createdAt— added in v0.7 after an audit found the reference implementation's reputation decay (§5.2) treated an unvalidated futurecompletedAtas younger than brand new, inflating that receipt's weight without bound rather than rejecting a nonsensical claim. - Finalized. The requester (
agent_a) reviews and countersigns the same canonical content. A registry MUST verify both signatures against the identical canonical content before settingstatus: "finalized"— it MUST NOT finalize on a single valid signature. Only once both verify does adispute.windowClosesAt(default 72h) get set; until then it MUST benull(v0.31, previously""). A client SHOULD treatnull, a missing value, or an unparseable one as "no window" rather than converting it to a date (in JavaScript,new Date(null)is 1970-01-01, which reads as a long-closed window); both SDKs shipdisputeWindowClosesAt/isDisputeWindowOpen(dispute_window_closes_at/is_dispute_window_open) for this, and (ifjobIdreferences a Job) that job transition tocompleted. This is the point a receipt becomes reputation-eligible. - Disputed. Either party MAY open a dispute before the window closes; a registry MUST record
dispute.openedByand MUST exclude a disputed receipt from the positive side of the reputation calculation and flag the agent's reputation responsein_dispute. - Resolved (v0.15, per-party in v0.22). The party that opened the dispute — and only that party — MAY withdraw it via
POST /receipts/:id/dispute/resolve, moving the receipt back tofinalizedanddispute.statusto"resolved"(withresolvedAt, an optionalresolutionnote, and the opener's DID appended todispute.usedBy). The receipt is reputation-eligible again and thein_disputeflag clears. This is one-way per party: a registry MUST reject a further dispute from any DID already present indispute.usedBywithDISPUTE_ALREADY_RESOLVED, so that party can't toggle the receipt's reputation contribution on and off — but MUST NOT reject a dispute from the other party on that basis, since each party has its own, independent dispute right on the same receipt. INAM defines no arbitration — a resolved dispute means "that party no longer contests it," not "a third party ruled." Binding third-party dispute resolution stays out of scope (§10). - Dispute resolution deadline (v0.22). When a dispute opens (step 3), a registry MUST set
dispute.resolutionDeadlineto the moment the dispute opened plus the same duration as the dispute-opening window (default 72h). Whiledispute.statusis"open"andresolutionDeadlinehas not yet passed, the receipt MUST stay excluded from the positive side of the reputation calculation as in step 3. OnceresolutionDeadlinepasses without resolution, a registry MUST treat the receipt as reputation-eligible again and clearin_disputefor both parties — an unresolved dispute otherwise excludes a receipt from reputation forever at zero cost to whoever opened it, which is a hostage mechanism, not a dispute process. This does not changestatusordispute.statusin storage (both remain"disputed"/"open") — only the reputation computation's treatment of the receipt; the opener MAY still calldispute/resolveafter the deadline to formally record"resolved".
A registry MUST treat the draft→finalized, finalized→disputed, and disputed→finalized (resolve) transitions as atomic compare-and-swap operations (transition only if the receipt is still in the expected prior state at write time), not read-then-write — concurrent requests targeting the same receipt are expected, and a race that lets two conflicting writes both succeed is a conformance bug, not an edge case to shrug off. worker/src/db.ts's finalizeReceiptIfDraft/disputeReceiptIfFinalized are the reference implementation of this requirement.
Both signatures are independently verifiable by anyone holding the JSON — a receipt does not need the issuing registry to be trusted or even online to be checked.
4.4 Visibility (v0.19)
visibility is "public" (default, and every pre-v0.19 receipt with no such field) or "participants_only", set once by agent_b at draft time (§4.3 step 1) and immutable thereafter — there is no update endpoint for it, the same principle §4 already applies to every other receipt field. It is operational metadata, like dispute/status: not part of the canonical content §4.2 hashes into receiptId, and not part of what either signature covers.
A participants_only receipt's full content is readable only by:
- either of its two parties (
agentA.idoragentB.id), or - an agent that has submitted a Verification (§12) referencing it.
A draft-status receipt (§4.3 step 1) is restricted the same way regardless of its own visibility value (v0.26) — a registry MUST treat status === "draft" as equivalent to participants_only for read purposes until the receipt is finalized or disputed, i.e. until agent_a has actually acted on it.
A registry MUST reject any other caller's GET /receipts/:id or GET /receipts/:id/verifications with RECEIPT_NOT_VISIBLE (403) — not 404, since the receipt's existence is not secret, only its content — and MUST silently omit a participants_only receipt the caller isn't entitled to from GET /agents/:id/receipts's list (filtering, not an error, so the endpoint's shape never changes). An agent listing its own receipts (:id matches the caller) always sees its full list, since it is a party to every receipt where it appears. A caller presenting no signature at all is treated as anonymous, not rejected outright — it simply sees only what a public receipt or its own participancy would show; a caller presenting an invalid signature is rejected the normal way (§7), not silently downgraded to anonymous.
A verifier must already appear in the receipt's Verification list to read a participants_only receipt via the API — so a first independent verifier the parties want to bring in necessarily receives the receipt JSON out-of-band from one of the parties (§4.3's closing line already establishes that both signatures are checkable by anyone holding the JSON, API access or not) before it can submit its own Verification and gain read access going forward. This is not a gap to fix: it's the same "the registry doesn't gate what a receipt's own parties choose to share" principle §0 already applies everywhere else.
Visibility gates who may read a receipt's content — it does not gate reputation. §5's computation includes a participants_only receipt exactly as it would a public one; hiding a receipt from public listing is not the same as excluding it from the trust it's evidence for.
5. Reputation Model
5.1 Event model
There is no separate "reputation event" ledger distinct from receipts. A finalized or disputed Execution Receipt is the event. This is a deliberate simplification, not an oversight: introducing a second, parallel event log would create two sources of truth that can drift. If a future need arises for reputation-affecting events with no underlying receipt (e.g. a governance-imposed penalty), it should be modeled as its own explicitly-typed event stream rather than force-fit into the receipt schema — out of scope for now.
5.2 Scoring
This section describes the reference implementation's algorithm, not a conformance requirement — a registry MAY compute trustScore differently as long as §5.3's response shape and the auditability principle below are honored: a registry MUST NOT report trustScore without also reporting the components that justify it, so a low-confidence score for a sparse-history agent is distinguishable from a bad one, not just an opaque number to trust blindly.
For an agent, the reference implementation computes reputation on demand (not cached/stored) from every finalized/disputed receipt it is party to:
- Counterparty-trust weighting. Each receipt's contribution is weighted by the counterparty's own independently-computed base trust (stake + volume + success ratio) — a one-step relaxation of a full EigenTrust fixed-point solve. Not yet enforced at full strength: this is single-pass, not an iterative solve over the whole interaction graph; that upgrade is deferred until there's enough transaction volume for it to matter.
- Sub-linear pair weighting, capped relative to an agent's other counterparties. Each receipt's pair-weight term grows with
log(pairCount)/pairCount, not linearly, so repeated receipts between the same two agents saturate rather than compound — but that alone only slows growth, it doesn't stop it. On top of it, once an agent has ≥3 finalized receipts, each counterparty's receipts count towardtrustScoreonly up tofloor(threshold/(1-threshold) * otherReceipts), whereotherReceiptsis that agent's finalized-receipt count with every other counterparty andthresholdis the 60% concentrated-counterparty ratio below — evaluated earliest-first byresult.completedAt, so genuine early history counts and a later flood against the same counterparty is what gets capped. The cap is deliberately sized off receipts a single counterparty can't inflate on its own: two otherwise-empty identities wash-trading only with each other haveotherReceipts = 0, so their cap is0— that pattern contributes nothing totrustScore, no matter its volume. A cap sized off the counterparty's own total instead (e.g. a flat 60% of all its receipts) would not achieve this: an attacker's flood inflates that total right along with the cap, so it never actually stops growing. - Time decay. Each receipt's weight decays with a configurable half-life (default 90 days) from
result.completedAt, clamped to[0, 1]— a registry MUST NOT let a receipt's decay factor exceed 1 (which an unvalidated futurecompletedAtwould otherwise produce, since the formula'sageDaysgoes negative) or fall below 0. §4.3'sINVALID_TIMESTAMPcheck prevents a futurecompletedAtfrom being accepted in the first place; this clamp is the defense for any receipt stored before that check existed, or any other source of a non-finite/out-of-range value — a registry MUST also treat a non-finite computed weight as zero contribution rather than letting it propagate into the running sums (NaNaddition would otherwise poison every other receipt's contribution to the same computation, not just the one with the bad value). - Stake component. A
sqrt(stakeUsd)term contributes to trust independent of transaction history, so a new but bonded agent isn't scored purely on zero history. Not yet enforced: there is no endpoint yet to actually post stake;stakeUsdexists in the data model and formula but defaults to 0 for every agent until the payments phase ships. - Concentrated-counterparty flag. If one counterparty accounts for more than 60% of an agent's finalized receipts (once there are ≥3), the response is flagged — the same 60% (as
thresholdin the pair-weighting bullet above) that bounds how much of that counterparty's volume can movetrustScorein the first place. A threshold heuristic, not real graph clustering (Leiden/Louvain), which is the documented next step. - Non-performance reports (§3.3, v0.25). The only negative-outcome path that requires no receipt at all — see §3.3 for the full mechanism. Each unique reporting poster contributes one zero-outcome weight (that poster's own base trust) to the accepted worker's
weightSum, deduped so repeat reports from one poster don't compound.
5.3 Response shape
{
"trustScore": 8.7,
"evidenceLevel": "countersigned",
"evidence": {
"source": { "declared": 0, "corroborated": 2, "independentlyVerified": 0 },
"construction": { "anchored": 2 },
"freshness": { "evaluatedAt": "2026-10-01T18:00:00.000Z", "excludedAttestations": 0 }
},
"components": {
"eigenWeight": 0.109,
"finalizedReceipts": 2,
"verifiedReceipts": 2,
"rawReceipts": 2,
"successRate": 1.0,
"volumeUsd": 0,
"volumeByCurrency": { "USDC": 25 },
"stakeUsd": 0,
"decayHalfLifeDays": 90,
"attestedReceipts": 0,
"rejectedAttestations": 0,
"nonPerformanceReports": 0,
"asProvider": { "receipts": 2, "successRate": 1.0, "volumeUsd": 0, "volumeByCurrency": { "USDC": 25 } },
"asRequester": { "receipts": 0, "successRate": 0, "volumeUsd": 0, "volumeByCurrency": {} }
},
"flags": []
}
components is returned in full, not collapsed into trustScore alone — the scoring is meant to be auditable, not a black box. A low score for a brand-new, unstaked agent with only one or two transactions is correct behavior, not a bug: eigenWeight (confidence) is deliberately slow to rise.
Field definitions worth stating explicitly, since an audit found several were easy to misread from name alone:
evidenceLevel(v0.32, top-level): the strongest evidence behind this history.none— no finalized receipts;countersigned— finalized receipts, none of which nets out to verified (only the two parties vouched for the work);independently_verified—attestedReceipts > 0. A consumer SHOULD read this before relying ontrustScore: a countersigned-only score rests entirely on the parties' own claims.evidence(v0.35, top-level): the evidence behind this history as separate counts rather than one level.source.declared:draftreceipts signed only by the provider, reported but never counted (§4.3).source.corroborated: receipts the counterparty countersigned that count toward reputation (equal tofinalizedReceipts).source.independentlyVerified: those whose counted Verifications net out to verified (equal toattestedReceipts).construction.anchored: counted receipts with areceipt_finalizedleaf in the transparency log (§13); receipts finalized before v0.30 have none.freshness.evaluatedAt: when this appraisal was computed.freshness.excludedAttestations: Verifications on counted receipts that were ignored because their verifier has since been de-authorized or revoked (§12.3). They are reported rather than silently dropped. The dimensions are deliberately not ranked against each other. BothevidenceandevidenceLevelare hints (§5.4).finalizedReceipts(v0.32): count of receipts both parties signed that count toward reputation (§4.3). Same value asverifiedReceiptsbelow, under a name that says what it means.rejectedAttestations(v0.32): finalized receipts whose counted Verifications net out to rejected (§12.5). Each is scored as a failed outcome and, when non-zero, sets theattestation_rejectedflag.verifiedReceipts(deprecated in v0.32 in favor offinalizedReceipts; still returned) means finalized — a receipt both parties signed — not independently verified. AverifiedVerification (§12) is a separate, stronger claim;attestedReceipts(below) counts those specifically. This naming is unfortunate in hindsight but not changed in this version —verifiedReceiptsalready ships in a live, published response shape, and renaming a field a consumer might already be parsing is a real breaking change, not a documentation fix. New registries SHOULD consider a less ambiguous name if starting from scratch.rawReceipts(v0.26): countsfinalized/disputedreceipts only, same asverifiedReceipts— adraftreceipt (§4.3 step 1, unilateral and unconsented-to byagentA) is excluded, matching §4.3's existing "MUST NOT count a draft receipt toward reputation" rule, which previously applied totrustScorebut not to this raw count.eigenWeightis the reference implementation's confidence term (§5.2's one-step relaxation of a full EigenTrust fixed-point solve), not an actual EigenTrust output — same "not yet enforced at full strength" caveat as §5.2 already states.volumeUsd/volumeByCurrency(volumeByCurrencyadded in v0.11): total settlementamountacross an agent's finalized receipts, from self-reported receipt data — not settlement-confirmed (INAM verifies no payment, §10).volumeByCurrencybuckets that total by each receipt'ssettlement.currency(normalized to upper-case; an untagged amount →"USD"), and a registry MUST NOT convert between or sum across currencies — INAM defines no exchange rate.volumeUsdis exactly the"USD"bucket (volumeByCurrency["USD"], or0); an audit found it previously added every currency's rawamounttogether as though they were all dollars. A stablecoin such asUSDCis its own bucket, not USD — the registry takes no position on any peg. A non-finite or negativeamountcontributes0.nonPerformanceReports(added in v0.25): count of jobs where this agent was the accepted worker and the poster later reported non-performance (§3.3) — not the same weighting as a receipt'soutcomeScore; see §5.2's new bullet.asProvider/asRequester(added in v0.8): the same weighted receipt count / success rate / volume as the aggregate above, split by which role this agent held on each finalized receipt (agentB/provider did the work;agentA/requester requested and countersigned it). Added because the aggregate fields above are role-blind by construction — two brand-new counterparties finishing one receipt produce identical-looking aggregate reputations for both of them regardless of which side each was on, which is misleading for a use case like "find an agent good at doing X work" (a provider-side question) versus "find an agent that reliably commissions and pays for work" (a requester-side question). This is not a separate 0-100 score per role — assigning real weights to aproviderScore/requesterScorepair (and a similarverifierScore, already-deferred verifier-side reputation per §12.7) is real scoring-model design work, not done here; a registry MAY compute such scores on top of this breakdown but this spec does not define one yet.- A registry MAY use a different formula for
trustScore(§5.2), but without a staking mechanism live (stakeUsdstays 0 for every agent until Phase 6/payments ships — see README's "Deliberate simplifications"), the reference implementation's specific formula (20·stakeComponent + 70·successRate·confidence + 10·confidence) has a mathematical ceiling around 80, not 100, no matter how much successful history an agent accumulates — worth knowing before treating the/100scale as implying 100 is reachable through work history alone.
5.4 The registry's appraisal is a hint (v0.35)
trustScore, evidenceLevel, and evidence are computed by the registry. To a relying party they are claims made by the platform, not findings: a dishonest or compromised registry could return any numbers. A relying party that needs a finding re-derives the evidence itself from signed records, and every input is public or available to the receipt's parties:
- Corroborated. Fetch the agent's receipts (
GET /agents/:id/receipts). For each one, recomputereceiptIdfrom the canonical content (§4.2) and verify both signatures:agentB's over the draft content, andagentA's countersignature. Each agent's public key is itsdid:key. - Independently verified. Fetch each receipt's Verifications (
GET /receipts/:id/verifications), recompute eachverificationId, and verify the verifier's signature (§12.1–12.2). Whether a verifier is authorized (isAuthorizedVerifier, §12.6) is itself a registry claim. A relying party with its own list of verifiers it trusts SHOULD apply that list instead. - Anchored. For each receipt, find its
receipt_finalizedentry, check that the entry'sdataHashmatches the receipt (§13.1), and verify its inclusion proof against a tree head the relying party, or a monitor it trusts, has retained and consistency-checked (§13.4). - Fresh. Re-check verifier authorization and agent revocation at the time of use. Report a check that could not be performed as not performed, never as passed.
A relying party MUST NOT record a stronger result than the checks it actually performed, and MUST fail an appraisal whose evidence contradicts the registry's claim rather than downgrade it. sdk-js and sdk-python export the signature, content-hash, and Merkle-proof functions these steps need (§8, §13.5).
6. REST API
Base path /v1. A (signed) endpoint MUST reject a request missing a valid signature (§7) or Idempotency-Key header — these are conformance requirements, not defaults a registry can silently relax. A registry MUST implement every endpoint below with the request/response shapes given; it MAY add endpoints beyond this list (e.g. its own admin/billing routes) but MUST NOT repurpose these paths for something incompatible with this spec.
| Method & path | Description |
|---|---|
GET /health |
Liveness check. |
POST /agents (signed) |
Register the calling INAM ID with a capability list and free-form metadata. |
GET /agents/:id |
Fetch an agent's public profile. |
GET /agents/:id/protocols |
Fetch an agent's linked external identities (linked) and their per-link assurance metadata (linkedProof, §2). |
GET /agents/:id/reputation |
Compute and return the reputation result (§5.3). No auth required — reputation is public by design. |
GET /agents/:id/badge.svg / GET /agents/:id/badge.json |
Embeddable trust-score badge (SVG, or shields.io endpoint JSON). |
GET /agents/:id/receipts |
List an agent's receipts (draft, finalized, and disputed). A participants_only receipt (§4.4) the caller isn't entitled to is silently omitted. |
POST /agents/:id/revoke (signed, self only) |
One-way retire this INAM ID (§2.2). Body { "reason": "..." }. Response 200 with the updated record. |
GET /agents/search?capability=&min_reputation=&supports=&limit=&offset=&include_demo= |
Discover agents by capability, minimum trust score, and/or which external protocol they support. Agents with metadata.demo: true are omitted unless include_demo=true (v0.33, §2). limit defaults to 50, clamped to 200; response includes hasMore (v0.28). |
POST /agents/:id/link/challenge (signed, self only) |
Request a single-use proof-of-control challenge before linking a key-derived external identity (§2.1). |
POST /agents/:id/link (signed, self only) |
Claim an external identity (§2); agentpass_id/aitp_id/passport_id/erc8004_id require a completed challenge (§2.1), a2a_endpoint does not. |
POST /jobs (signed) |
Post an open job (§3). |
GET /jobs/:id |
Fetch a single job. JOB_NOT_VISIBLE (403, v0.26) if its linked receipt is participants_only (§3.4) and the caller isn't a party or attesting verifier. |
GET /jobs/search?capability=&status=&limit=&offset= |
Discover jobs, typically filtered to status=open. A job gated by §3.4 is silently omitted rather than erroring. limit defaults to 50, clamped to 200; response includes hasMore (v0.28). |
POST /jobs/:id/offers (signed) |
Submit an offer on an open job. |
GET /jobs/:id/offers |
List a job's offers. |
POST /jobs/:id/accept (signed, poster only) |
Accept one offer, moving the job to accepted. |
POST /jobs/:id/cancel (signed, poster only) |
Cancel a not-yet-completed job. |
POST /jobs/:id/report-nonperformance (signed, poster only) |
Report that the accepted worker never delivered (§3.3). |
POST /receipts (signed) |
Submit a draft receipt, signed by agent_b. |
GET /receipts/:id |
Fetch a single receipt. RECEIPT_NOT_VISIBLE (403) if it's participants_only (§4.4) and the caller isn't a party or attesting verifier. |
POST /receipts/:id/countersign (signed, agent_a only) |
Countersign a draft receipt, finalizing it. |
POST /receipts/:id/dispute (signed, participant only) |
Open a dispute within the window (§4.3). |
POST /receipts/:id/dispute/resolve (signed, dispute opener only) |
Withdraw an open dispute, disputed → finalized (§4.3). Body { "note"?: "..." }. One-way per party — spends that party's own dispute right, not the other party's. |
POST /verifications (signed, caller = verifier) |
Submit a signed independent verification of a finalized receipt (§12). |
GET /verifications/:id |
Fetch a single verification. |
GET /receipts/:id/verifications |
List verifications referencing a receipt. RECEIPT_NOT_VISIBLE (403) applies the same as GET /receipts/:id (§4.4). |
POST /agents/:id/verifier-status (signed, caller = operator only) |
Grant or revoke :id's verifier authorization (§12.3, §12.6). |
GET /transparency/sth |
Current transparency log tree size + root hash (§13). Unsigned. |
GET /transparency/entries?limit=&offset= |
List raw log entries, oldest first. limit defaults to 50, clamped to 200; response includes hasMore. Each entry has data (the canonical entry bytes its leafHash covers) and payload (v0.34: the canonical payload its dataHash commits to, or null if withheld or erased, §13.3). |
GET /transparency/proof/inclusion?leafIndex=&treeSize= |
Merkle audit path proving leafIndex is included in the tree at treeSize (defaults to current size). |
GET /transparency/proof/consistency?first=&second= |
Proves the tree at size first is a prefix of the tree at size second (defaults to current size) — detects retroactive rewriting. |
Error shape
{ "error": { "code": "AGENT_NOT_FOUND", "message": "..." } }
Codes in the current implementation: MISSING_SIGNATURE, STALE_SIGNATURE, INVALID_SIGNATURE, MISSING_IDEMPOTENCY_KEY, VALIDATION_ERROR, INVALID_JSON, AGENT_NOT_FOUND, AGENT_ALREADY_REGISTERED, NOT_SUBJECT_AGENT, UNSUPPORTED_PROTOCOL, RECEIPT_NOT_FOUND, SELF_DEALING, DUPLICATE_RECEIPT, INVALID_RECEIPT_SIGNATURE, NOT_DRAFT, NOT_REQUESTER, NOT_FINALIZED, DISPUTE_WINDOW_CLOSED, NOT_PARTICIPANT, ROUTE_NOT_FOUND, RATE_LIMITED, JOB_NOT_FOUND, JOB_NOT_OPEN, JOB_NOT_ACCEPTED, JOB_NOT_CANCELLABLE, JOB_PARTY_MISMATCH, NOT_POSTER, OFFER_NOT_FOUND, OFFER_ALREADY_SUBMITTED, RECEIPT_NOT_FINALIZED, NOT_VERIFIER, SELF_VERIFICATION, VERIFICATION_TARGET_MISMATCH, UNSUPPORTED_VERIFICATION_METHOD, INVALID_VERIFICATION_SIGNATURE, DUPLICATE_VERIFICATION, VERIFICATION_NOT_FOUND, VERIFIER_ALREADY_DECIDED, INVALID_TIMESTAMP (v0.7, §4.3), VERIFIER_NOT_AUTHORIZED, NOT_OPERATOR (v0.10, §12.6), REPLAYED_REQUEST (v0.12, §7), AGENT_REVOKED (v0.14, §2.2), DISPUTE_ALREADY_RESOLVED / NOT_DISPUTED / NOT_DISPUTE_OPENER / JOB_EXPIRED (v0.15, §3.2/§4.3), JOB_NOT_REPORTABLE / TOO_EARLY_TO_REPORT (v0.25, §3.3), JOB_NOT_VISIBLE (v0.26, §3.4), UNSUPPORTED_SIG_VERSION (v0.29, §7), INVALID_TREE_SIZE / INVALID_LEAF_INDEX (v0.30, §13) (this list previously omitted the §12 Verification codes and the §7-adjacent INVALID_JSON, an existing documentation gap fixed alongside the v0.6 changes above, not new behavior).
Rate limiting
POST /agents is limited per source IP (a DID costs nothing to mint, so limiting by identity would do nothing against a spammer generating fresh keypairs). Every other (signed) write — including all Job endpoints — is limited per calling INAM ID. GET /agents/search and GET /agents/:id/reputation — the two reads expensive enough to walk an agent's full receipt history — are limited per source IP even though they require no signature, since they're otherwise a free way to trigger repeated O(receipts) backend reads. A 429 with code RATE_LIMITED means back off and retry later; specific limits are a deployment policy, not a protocol guarantee, and may differ between registries.
CORS
Public GET reads respond with Access-Control-Allow-Origin: * — they're meant to be queryable from a browser with no account, matching §6's "reputation is public" design. Signed mutating routes send no CORS headers at all: they're server-to-server/agent-to-agent by design, and since auth is a per-request Ed25519 signature rather than an ambient browser credential (a cookie, say), CORS restriction there is a scope-narrowing choice, not a security boundary — a malicious web page still can't forge a signature it doesn't hold the private key for.
7. Request signing
Every mutating call is signed by the caller's own INAM ID — there is no separate API-key concept. This is a simplified, RFC 9421 (HTTP Message Signatures)-inspired scheme, not full structured-field compliance:
inam-agent: did:key:z...
inam-timestamp: <unix ms>
inam-sig-version: 2
inam-signature: base64(Ed25519(
`${METHOD}\n${fullPath}\n${host}\n${timestamp}\n${sha256hex(rawBody)}`
))
fullPath MUST be the complete request path including any mount prefix (e.g. /v1/agents/:id/link), not a router-relative path — implementers behind a sub-router MUST sign/verify against the full original URL, or every signature on a sub-routed endpoint fails to verify (this broke the reference implementation once; see worker/src/signedRequest.ts's doc comment). A registry MUST reject a request whose timestamp is outside its configured clock-skew window (5 minutes in the reference implementation) to bound replay; it SHOULD use a window in that range — wide enough to tolerate real clock drift, narrow enough that a captured request/signature can't be replayed indefinitely.
Host binding (inam-sig-version: 2, v0.29). host MUST be the exact Host header of the request being signed. A signer includes the host it believes it is talking to (an SDK derives this from its own configured base URL, at no extra cost to the caller); a verifier MUST always plug in its own actual incoming Host header when recomputing this string — never a client-supplied value — so a captured v2-signed request cannot be replayed against a different host: the recomputed string won't match what was signed. This matters for any registry serving more than one hostname (the reference Worker deployment is dual-hosted: a custom domain plus its *.workers.dev fallback) and for the open-source ecosystem generally, where a signature captured against one deployment of this code must not also verify against another. A verifier MUST reject a request whose inam-sig-version is present and neither absent nor "2" as UNSUPPORTED_SIG_VERSION (401).
Migration. A registry MUST continue to accept the legacy v1 string (no inam-sig-version header, no host line) for callers on an older SDK version — this is additive, not a breaking cutover. v1 acceptance is deprecated as of v0.29 and MAY be removed in a future SPEC version.
Idempotency: a mutating endpoint MUST require an Idempotency-Key header. A registry MUST cache the response of a terminal successful (2xx) operation and replay it for a repeated (caller, key) pair instead of re-executing; it MUST NOT cache a non-2xx response (a transient 5xx/429 would otherwise pin that failure for the cache TTL and make a legitimate retry impossible) — a non-2xx leaves the key unclaimed and a retry re-executes.
Replay: the signing string above does not cover the Idempotency-Key, so a captured signed request replayed with a fresh key would re-verify and miss the idempotency cache. To close this, a registry MUST bind each verified request signature to the single Idempotency-Key it was first presented with, for at least the clock-skew window, and MUST reject the same signature presented with a different key as REPLAYED_REQUEST (409). The same signature with the same key is a normal retry (served from the idempotency cache if a success was recorded, otherwise re-executed). The reference implementation keeps this binding in the same store as the idempotency cache — in-memory for /src (lost on restart; a real multi-instance deployment needs a shared TTL store), KV for /worker (its cross-edge eventual consistency leaves a ~60s cross-location race, with each operation's own content-address / state-machine guards as the backstop; a hard guarantee needs a Durable Object or D1).
This binding MUST be keyed off the decoded signature bytes, not the inam-signature header string verbatim (v0.26) — a base64-encoded 64-byte Ed25519 signature has a final character whose low bits are unused by any standard decoder, so a captured signature can be re-encoded into a different string that decodes to the exact same bytes; hashing the raw header string let such a re-encoding sail past the guard above as if it were a brand-new, never-before-seen signature.
8. SDK architecture
An INAM SDK, in any language, MUST provide:
- Keypair generation and INAM ID encoding (§2) —
did:keyfrom an Ed25519 public key. - Canonical JSON serialization (§4.2) matching
sdk-js/src/crypto/canonical.tsbyte-for-byte — this is the one piece of logic that MUST be identical across every language implementation; two SDKs disagreeing here sign/verify different bytes for what looks like the same receipt, and neither will notice until a cross-SDK countersign fails. - Request signing per §7.
- Receipt content + ID construction per §4.2 (see
sdk-js/src/core/receiptContent.tsfor the reference logic). - A thin client wrapping the REST calls in §6:
registerAgent,getAgent,linkIdentity,requestLinkChallenge,completeLink,searchAgents,getReputation,listReceipts,submitWork(draft),acceptWork(countersign),disputeReceipt.acceptWorkMUST NOT blindly sign whatever receipt content it is handed (v0.26) — a caller commonly fetches a draft and passes it straight through, which for an LLM-driven caller is a real prompt-injection surface via the receipt's own free-text fields. An SDK'sacceptWorkMUST at minimum refuse to sign a receipt whoseagentA.idisn't the calling client's own identity, and SHOULD accept an optional caller-supplied expectation (at leastjobId/outputHash) to validate against the fetched draft before signing, sourced from the caller's own prior knowledge of the job rather than the fetched (untrusted) object itself. - P-256 sign/verify (§2.1) alongside Ed25519, for external-identity challenge proofs — the low-S canonicalization requirement in §2.1 applies to the SDK's signer, not just the registry's verifier.
An SDK SHOULD ship a fixed-vector interop test — sign/canonicalize a known payload with a known test key and compare byte-for-byte against a value generated by another language's SDK — rather than relying on end-to-end demos alone to catch a canonicalization drift. sdk-python/tests/test_interop.py and scripts/interop-vectors.ts are the reference pattern.
Reference implementations: inamprotocol on npm (TypeScript, InamClient; source sdk-js/src/client.ts) and inamprotocol on PyPI (Python, InamClient; source sdk-python/inamprotocol/client.py). Cross-language interop is a first-class correctness requirement — a receipt drafted by the Python SDK must countersign correctly against a TypeScript client and vice versa, because both compute the identical canonical bytes. Both SDKs also provide Job methods (postJob/post_job, searchJobs/search_jobs, submitOffer/submit_offer, acceptOffer/accept_offer, cancelJob/cancel_job) — see sdk-python/examples/job_demo.py for the full flow — and external-identity link-challenge methods (requestLinkChallenge/request_link_challenge, completeLink/complete_link) — see sdk-python/examples/link_challenge_demo.py. The P-256 canonicalization requirement in §2.1 is a real cross-language interop hazard, not a hypothetical one: this reference implementation's own Python signer initially produced non-canonical signatures the TypeScript verifier rejected about half the time, caught by running the demo repeatedly rather than by a single passing run.
9. Versioning
receiptVersion and the /v1 API path version independently. A breaking change to the receipt schema increments receiptVersion; a breaking change to the API surface ships as /v2, with /v1 kept live for at least 18 months (target — not yet tested in practice, no protocol version has shipped a breaking change yet).
10. Explicitly out of scope
Not deferred by accident — deferred because building them before the primitives above are solid would be premature:
- Automatic job status transition on expiry. As of v0.15 an expired job is rejected from
submitOffer/acceptOfferat the gates (JOB_EXPIRED, §3.2), but its storedstatusstill readsopen— flipping it to a terminalexpired/cancelledstate on its own needs a background sweeper, which stays deferred. - Payments/settlement enforcement (
settlementand a job'sbudgetare recorded, and theiramount/currencyare shape-validated (§4.1), but never verified against x402/AP2/on-chain state, and no exchange rate between currencies is defined —components.volumeByCurrency(§5.3) reports self-reported volume bucketed by currency, unconverted). - Stake posting/slashing endpoints.
- Key rotation / signed successor-chain. §2.2's
revokeis one-way — burn the ID. It does not let an agent name a new INAM ID as successor and carry reputation across (with a signature from the old key proving the link). Doing that safely — how much reputation carries, how a consumer verifies the chain, what stops a compromised key from naming an attacker's ID as successor — is real design work, deferred until there's demand for it. - A hosted execution or verification runtime. A registry runs neither agent work nor the checks behind a Verification (§12) — it records their signed results. §12's verifier performs its
deterministic/agent_attestationcheck in its own environment and submits the signed outcome; the registry validates the signature, the operator grant, and the output-hash match, and stores it (§0, §12.8). Running verification (or agent work) as a service the registry operates and pays for is a different product, not a gap in this one. - TEE remote attestation for
verification.method: independent_validator. - Live cross-registry resolution for linked external identities: §2.1's challenge proves the caller holds the claimed external key today, but a registry does not call out to AgentPass/AITP/Passport Alliance's own APIs to confirm that key is still the one each system currently recognizes as authoritative (e.g. it wouldn't catch a rotated or revoked external key), nor that the key belongs to the
valuestring being linked.linkedProof(§2) makes this assurance level explicit per link (key_possessionvsunverified_claim) rather than closing the gap. - ATTP conformance certification — §2.1's wire format aligns with
draft-sharif-attp-00by design, but this has not been tested against a live ATTP verifier. - Trust-score penalties for repeated failed challenge attempts (ATTP §4 recommends this; this reference implementation just lets the challenge expire normally after a failed attempt).
- Full iterative EigenTrust solve and real graph-clustering-based collusion detection.
- Ranking/matching logic for job offers beyond a flat list (e.g. sorting offers by the offering agent's reputation) — a registry MAY add this as a read-side convenience without a spec change, since it doesn't affect wire format or conformance.
- Any UI, marketplace, or payment product surface.
11. Relationship to other protocols
| Protocol | Layer | INAM's relationship |
|---|---|---|
| MCP | Agent ↔ Tool | Complementary — an INAM SDK can be exposed as an MCP server's tools. |
| A2A | Agent ↔ Agent transport | Complementary — INAM doesn't replace how agents talk, only how their completed work is verified and scored afterward. |
| AgentPass, AITP, Passport Alliance, W3C DID/VC | Identity & delegation | Complementary — linked (§2) references these; INAM does not mint or arbitrate authorization. §2.1's link-challenge wire format follows ATTP (the trust-transport protocol AgentPass is built on) so proof-of-control interops with that ecosystem's own key material, but INAM still doesn't call out to their registries as the authority on delegation/mandate scope — that stays theirs. |
| x402, AP2, ACP | Payment | Complementary — settlement.paymentRef (§4.1) and a job's budget (§3.1) are designed to hold a reference into one of these; INAM does not move money itself. |
| OpenWork.network | Agent marketplace / economic-history | Overlapping claim, different architecture — revised 2026-08-24; the "zero public repos" framing above is stale. OpenWork now ships a live on-chain agent marketplace (an OpenworkEscrow contract, a $OPENWORK token on Base, wallet-based agent identity, competitive job bidding) — a crypto-native, on-chain-settlement design, versus INAM's off-chain, settlement-agnostic reference receipts. No independently verifiable usage/volume data was found for it beyond its own site's feature claims, so treat "launched a token and an escrow contract" as a real architecture, not confirmed traction. Note: an unrelated desktop-agent product also uses the "OpenWork" name (e.g. different-ai/openwork, modelstudioai/openwork on GitHub) — do not conflate the two when researching this row further. |
| ERC-8004 (Trustless Agents) | On-chain agent identity, reputation, validation | Complementary at the identity layer, direct alternative at the reputation layer — see §11.1. ERC-8004's Identity Registry is a discovery/identity primitive INAM has no equivalent of and doesn't try to be. Its Reputation Registry and INAM's execution receipts solve the same problem with opposite trust assumptions. |
11.1 INAM and ERC-8004
ERC-8004 ("Trustless Agents", Draft) defines three Ethereum registries: Identity (an ERC-721-style on-chain agent identity pointing at an off-chain agent card), Reputation (giveFeedback() / revokeFeedback() — signed client scores), and Validation (independent validator attestations via TEE / zkML / stake-secured re-execution). It reached ~200K registrations across 20+ chains within its first three months.
Identity layer — complementary. ERC-8004's Identity Registry is a public, on-chain, chain-native directory. INAM has no such primitive and does not want one (§0: identity issuance and discovery are out of scope). The natural composition is an ERC-8004 identity that also carries an INAM ID, with INAM execution receipts referencing the on-chain identity. INAM's linked map (§2) is the mechanism: an ERC-8004 identity is an EVM address (secp256k1), and §2.1's link-challenge closes that gap as of v0.18 — keyType: "secp256k1" plus an erc8004_id link protocol, with the claimed address verified against the proven key (not just an opaque claim) since an Ethereum address is itself key-derived. Still not in scope, here or elsewhere: live on-chain resolution against the Identity Registry contract itself (whether the linked address is still the one ERC-8004 currently recognizes) — same boundary as agentpass_id/aitp_id/passport_id's existing cross-registry-resolution gap (§10).
Reputation layer — direct alternative, opposite trust model. ERC-8004's Reputation Registry accepts client feedback as signed on-chain scores. A 2026 empirical study of the deployed ecosystem ("Can Trustless Agents Be Trusted?") found that, as deployed:
- 95.4% of Ethereum feedback, 100% of BSC, and 98.7% of Base feedback carried no payment proof and no task linkage — the score is not tied to any specific piece of work.
- Coordinated Sybil behavior in 73.6% of Ethereum reviewers, 59.2% BSC, 90.6% Base; manipulating a score costs ~$0.0027 per feedback on Base.
- The Validation Registry had no confirmed mainnet deployments during the observation window.
- The study's verdict: the Reputation Registry "as currently deployed, cannot function as a reliable trust signal."
INAM's design targets exactly these failure modes:
| Failure mode in ERC-8004's Reputation Registry (as deployed) | INAM's structural answer |
|---|---|
| Feedback not linked to any task | Reputation is computed only from finalized execution receipts (§5.2); a receipt names a job, a spec hash, and an output hash (§4.1) — there is no free-standing "score" to give. |
| One party can post a score unilaterally | A receipt only counts once both parties have signed it (draft + countersign, §4.2). |
| Sybil reviewers inflate reputation for ~$0 | A new counterparty contributes almost nothing: eigenWeight (§5.2) discounts receipts from low-reputation identities, so minting throwaway identities to vouch for yourself doesn't move the score. |
| "Independent validator" is self-declared | A Verification (§12) only contributes if its verifier was explicitly granted verifier status by the registry operator (§12.3 rule 4) — there is no self-service path. |
Publishing a receipt as ERC-8004 feedback (v0.38). The two models compose: a finalized INAM receipt can be posted to ERC-8004's Reputation Registry as evidence-backed feedback.
- Who sends it. The receipt's requester (
agentA) callsgiveFeedbackfrom itslinked.erc8004_id(§2.1), on the provider's ERC-8004agentId. Only afinalized,publicreceipt MAY be published: the feedback file is public, so publishing aparticipants_onlyreceipt (§4.4) would expose it. - Arguments.
valueis100,50, or0for the receipt'sverification.outcomeofsuccess,partial, orfailed, withvalueDecimals0.tag1isinam-receipt,tag2istask.capability.feedbackHashis the keccak256 of the feedback file's exact bytes. - Feedback file. ERC-8004's off-chain feedback file, serialized with
canonical()(§4.2), plus one field,inam.receipt: the receipt's signed content (§4.3) and both signatures, withoutstatus,dispute, orvisibility.clientAddressis the requester's address in CAIP-10 form. - Checking it. A reader holding the
NewFeedbackevent and the file MUST treat the feedback as INAM-backed only if: the file hashes tofeedbackHash; the receipt'sreceiptIdmatches its content and both signatures verify;valuematches the outcome; the event'sclientAddressequals the requester'slinked.erc8004_id; and the registry the reader trusts reports the receiptfinalized. That the ERC-8004agentIdbelongs to the receipt's provider is checked on-chain against the provider'slinked.erc8004_id, which INAM does not do.
The sender check is what answers the study's Sybil finding: copying a genuine file to another wallet fails it. examples/erc8004-feedback.ts runs this against a local registry without sending a transaction.
This is not a claim that INAM's model is trustless — it is explicitly not (it has a registry operator, §0). It is a different trade: INAM gives up chain-native permissionlessness to get task-linked, bilaterally-signed, Sybil-discounted reputation. An agent can hold both an ERC-8004 identity (for on-chain discovery) and an INAM reputation (for "did this specific job actually happen, and did both sides agree").
11.2 x402: verify before paying (v0.36)
An x402 v2 payee MAY name its INAM ID in its PaymentRequired object as the extension inam:
"extensions": { "inam": { "info": { "did": "did:key:z6Mk..." }, "schema": { ... } } }
A payer that wants to pay only agents with a track record checks, before it signs any payment:
- The named ID resolves in the registry and is not revoked (§2.2).
- Binding. Every
accepts[].payToit may pay equals (case-insensitively) the ID'slinked.erc8004_id, the EVM address the ID proved control of (§2.1). Without this check, a server could name a reputable agent's ID and route the money to its own wallet. A payer MUST narrowacceptsto the bound entries, and MUST NOT pay if none remain. - Policy. The ID's
evidenceLevelandtrustScore(§5.3) meet the payer's own thresholds. These are the registry's hint (§5.4); a payer that wants a finding re-derives them first.
The extension is advisory metadata, so a payee that omits it is simply unknown to INAM, not invalid. The registry takes no part in the payment and needs no new endpoint. sdk-js ships this as withInamX402Gate(fetch, client, policy), composed inside an x402 payment wrapper so a blocked payee throws before anything is signed, plus inamX402Extension(did) for payees; examples/x402-verify-before-pay.ts runs it end to end against a local registry. Only EVM payTo addresses can be bound today, because erc8004_id is the only linked identity that is a payment address.
11.3 A2A: INAM ID on an Agent Card (v0.37)
An A2A agent MAY name its INAM ID on its Agent Card with the data-only extension https://inamprotocol.org/ext/a2a/v1 (specification published at that URI), as one capabilities.extensions entry:
{ "uri": "https://inamprotocol.org/ext/a2a/v1", "required": false, "params": { "did": "did:key:z6Mk..." } }
The agent MUST NOT mark it required; it changes no A2A request or response. A client relying on it:
- Fetches the named ID and its reputation from a registry the client trusts; the card does not choose the registry.
- Binding. Requires that at least one endpoint on the card (
supportedInterfaces[].urlin A2A 1.0;urloradditionalInterfaces[].urlin 0.3) equals the ID'slinked.a2a_endpoint(§2), ignoring a trailing slash. Otherwise it MUST treat the card as naming no INAM ID. The card names the ID, and the ID names the endpoint under its own signature (§7), so neither side can be borrowed alone. This is weaker than a key proof:a2a_endpointis anunverified_claim(§2), so an operator who controls both can still link them. - Rejects a revoked ID, then applies its own policy to
evidenceLevel/evidence/trustScore, treating them as a hint (§5.4) exactly as in §11.2.
sdk-js ships inamA2AExtension(did) for agents and verifyA2ACard(card, client, policy) for clients; examples/a2a-agent-card.ts runs both against a local registry.
12. Verification (independent attestation)
A receipt's verification.method (§4.1) can be independent_validator or test_suite_pass, but until now neither had any enforcement behind it — a receipt could claim either value with nothing checking it. This section adds a Verification resource: a third party's signed attestation that a specific finalized receipt's output actually satisfies its job's requirements. It sits after Receipt in the chain, not inside it:
Job → Execution → Receipt (finalized) → Verification → verified / rejected
v0.1 is deliberately narrow. Locked for this version — not because these are bad ideas, but because shipping all of them at once is how a spec ends up with untested corners: single verifier per receipt (no multi-verifier consensus), provider != verifier strictly enforced (no exceptions), only deterministic and agent_attestation methods, no new dispute mechanism, no verifier-side reputation, no external-registry passthrough (OpenWork/AgentPass/etc. attestations). Multi-verifier consensus, human_attestation/external_attestation, and verifier reputation are the explicit v0.2 backlog (§12.7) — a registry MUST NOT need any of them to conform to this version.
12.1 Shape
{
"verificationVersion": "1.0",
"verificationId": "sha256:<hex>",
"receiptId": "sha256:...",
"jobId": "job_7f31c2",
"provider": "did:key:z...",
"verifier": "did:key:z...",
"method": "deterministic",
"outputHash": "sha256:...",
"result": "verified",
"score": 0.98,
"evidenceUri": "https://...",
"createdAt": "2026-08-22T15:00:00Z",
"signature": "base64..."
}
provider and jobId are not independently supplied by the caller — a registry MUST derive both from the referenced receiptId (provider = that receipt's agentB.id, jobId = that receipt's jobId) rather than trusting client-asserted values that could disagree with the receipt itself. outputHash MUST match the referenced receipt's result.outputHash exactly — a registry MUST reject a mismatch with VERIFICATION_TARGET_MISMATCH rather than silently accepting an attestation about different output than what the receipt actually recorded. evidenceUri is optional and, like outputUri on a receipt (§4.1), a pointer, not a payload — same "carry the proof, not the data" principle as the rest of the spec. There is no separate requirementsHash: the job's own specHash (§3.1) is what the verifier is expected to have checked the output against.
method is one of deterministic (an automated test/check ran and produced a pass/fail) or agent_attestation (another agent examined the work and attests to it) — a registry MUST reject any other value with UNSUPPORTED_VERIFICATION_METHOD in this version. This is a different field from the receipt's own verification.method (§4.1's naming note above) — same name, unrelated enum, unrelated enforcement. result is verified or rejected — the verifier's own signed judgment, recorded either way; a rejected verification is not an error, it's a legitimate, queryable attestation that the work did not hold up. score is optional, 0..1, a confidence/quality signal a registry MAY ignore.
12.2 Verification ID (content addressing)
verificationId = "sha256:" + hex(sha256(canonical({
receiptId, jobId, provider, verifier, method, outputHash, result, score, evidenceUri
})))
The signed content is this same set of fields plus verificationVersion and verificationId itself (the already-computed hash) — i.e. signature = Ed25519(canonical({ verificationVersion: "1.0", verificationId, receiptId, jobId, provider, verifier, method, outputHash, result, score, evidenceUri }), verifierPrivateKey), mirroring exactly how a receipt's signed content embeds its own receiptId alongside the fields that were hashed to produce it (§4.1–§4.2). createdAt is server-assigned metadata, not part of the signed content — there's no window or reactivation logic here that depends on it the way a receipt's dispute.windowClosesAt does.
Same content-addressing principle as a receipt (§4.2): the id is derived from the content, not assigned. A registry MUST treat a resubmission of byte-identical content as DUPLICATE_VERIFICATION.
12.3 Creating a verification
POST /verifications (signed, caller MUST be the verifier). Unlike a receipt, a verification has no draft/countersign step — it's a single party's attestation, signed once, complete on submission. A registry MUST validate, in order, before accepting:
- The referenced
receiptIdexists and isstatus: "finalized"(§4.3) — MUST reject withRECEIPT_NOT_FINALIZEDotherwise. Verifying adraftreceipt makes no sense (it isn't reputation-eligible yet); verifying adisputedone is handled by §12.4 below, not by rejecting the request outright. - The request's own INAM signature (§7) is by the same ID as
verifierin the submitted content — MUST reject a mismatch withNOT_VERIFIER. verifieris not equal toprovider(the receipt'sagentB.id) orrequester(the receipt'sagentA.id) — MUST reject a self-verification attempt from either party withSELF_VERIFICATION. This is the core collusion guard: without excluding both parties, either could rubber-stamp the work the receipt already records them agreeing on (v0.5 excluded only the provider — the requester, who already approved the same work by countersigning it, was left able to name itself "independent verifier" with no check at all).verifieris a registered agent (§2) and has been explicitly authorized as a verifier by the registry's operator identity (v0.10 — see §2, §12.6) — MUST reject an unregisteredverifierwithAGENT_NOT_FOUND, and MUST reject a registered-but-unauthorizedverifierwithVERIFIER_NOT_AUTHORIZED. Being merely registered is not sufficient: registration is free and self-service, so "verifier is a registered agent" alone never restricted who could verify, only that they'd taken the zero-cost step of registering first — it made verifier count meaningless as an independence signal (an audit found this directly undermines §12.5's verified-vs-rejected tiebreak, which assumes each verifier represents a real, distinct grant of trust). Authorization is the actual gate; registration is just a prerequisite for it. This still does not establish that the verifier is a genuinely independent legal or organizational entity — no cryptographic scheme can, on its own (see §0's boundary: identity/authorization is explicitly not this protocol's job) — only that the registry operator has deliberately vouched for this specific identity as a verifier, rather than anyone being able to claim that status for themselves.outputHashmatches the receipt'sresult.outputHash— MUST reject a mismatch withVERIFICATION_TARGET_MISMATCH(§12.1).methodis a supported value (§12.1).- The
signatureverifies againstverifier's Ed25519 key over the canonical bytes of the content in §12.2 (includingverificationVersionandverificationId, excludingsignatureitself) — MUST reject withINVALID_VERIFICATION_SIGNATUREotherwise. Same canonicalize-then-sign discipline as a receipt, reusing the identicalcanonicalize()/Ed25519 primitives (§4.2, §8); this version introduces no new signing scheme. - A resubmission of byte-identical content (same
verificationId) — MUST reject withDUPLICATE_VERIFICATIONrather than creating a second record, same principle as a receipt (§4.2). Checked before rule 9: an exact resubmission is a harmless no-op (e.g. a client retrying after a dropped response) and MUST be reported as such, distinct from actually attempting a second, different decision. - This
verifierhas not already submitted a verification for thisreceiptId(regardless of content) — MUST reject a second, differently-shaped decision from the same verifier withVERIFIER_ALREADY_DECIDED. Without this, the same verifier could submitverifiedand, separately,rejectedfor the same receipt — different content means rule 8's content-hash check doesn't catch it — leaving both as live, contradictory records with no way to tell which is authoritative. A registry MUST NOT interpret this as a way to "update" a decision; a verifier's first decision for a receipt is final in this version (multi-verifier consensus / decision supersession is explicit v0.2 backlog, §12.7).
A registry MUST create the record with whatever result the verifier signed (verified or rejected) once all checks pass — a registry MUST NOT substitute its own judgment for the verifier's. Response 201 with the full record.
12.4 Relationship to Receipt disputes — no new dispute mechanism
This version deliberately does not add a second dispute concept. A receipt's existing dispute state (§4.3) is the only dispute mechanism in the protocol; Verification does not get its own.
- A registry MUST check the referenced receipt's current
statusbefore letting averifiedVerification contribute to reputation (§12.5) — if the receipt isdisputed, it stays excluded from the positive side of the reputation calculation exactly as §4.3 already requires, regardless of any Verification referencing it. A verified Verification does not resurrect a disputed receipt. - A registry MUST likewise check the verifier's current
isAuthorizedVerifierstatus before letting its Verification contribute to reputation (§12.5), not only at submission time (v0.23). Revoking a verifier (§12.3) MUST stop its already-submitted records from continuing to count toward theverified-vs-rejectedmajority — a registry MUST NOT let an identity the operator no longer vouches for keep boosting reputation indefinitely off attestations made while it was still authorized. This uses the verifier's current status, not the status at the time it verified; a registry is not required to keep a point-in-time authorization history to make this determination. - Disputing a receipt does not retroactively delete or invalidate an existing Verification record — it stays queryable as historical evidence (what a verifier attested, and when), only its effect on reputation is suppressed while the receipt remains disputed. If the dispute is later resolved (§4.3 — the opener withdraws it, moving the receipt back to
finalized), the Verification's contribution resumes automatically, since the check in §12.5 is computed live from current receipt status, not cached at verification-creation time. - A
rejectedVerification does not automatically open a dispute on the receipt in this version — a registry MUST NOT infer an automatic state transition on the receipt from a Verification result. A rejected Verification is evidence a receipt's own parties (or a future version of this spec) could act on; auto-disputing on rejection is explicit v0.2 backlog (§12.7).
12.5 Reputation linkage
No new event stream (§5.1's reasoning applies equally here: a second parallel ledger is a second source of truth waiting to drift). Instead, computeReputation (§5.2) MUST apply an additional weight multiplier to a finalized, non-disputed receipt whose independent-verification evidence nets out to verified, counting only Verification records whose verifier is both currently operator-authorized (v0.22) and not revoked (v0.27 — closes the gap where a verifier revoking itself, rather than the operator revoking its status, left isAuthorizedVerifier frozen and un-clearable) — a registry MAY choose its own multiplier (the reference implementation uses a fixed boost; see src/services/reputationService.ts), but MUST apply it consistently in the same direction (independently-verified work counts for more, never less). A receipt whose counted Verifications net out to rejected (strictly more rejected than verified, same filter as above) MUST be scored as a failed outcome (outcomeScore 0) regardless of the receipt's self-declared verification.outcome, and MUST receive no boost (v0.32 — through v0.31 a rejection was scoring-neutral, so an external test saw a rejected receipt still raise trustScore with successRate at 100%). A tie, including no counted Verifications at all, leaves the self-declared outcome in place. A registry MUST report the count as components.rejectedAttestations and set the flag attestation_rejected when it is non-zero (§5.3).
"Nets out to verified" (v0.9, tightened from v0.1's "at least one verified Verification exists"): a registry MUST count both verified and rejected Verifications referencing the receipt and MUST NOT apply the boost unless verified strictly outnumbers rejected. §12.3 places no limit on how many different verifiers may independently verify the same receipt (only §12.3 rule 9 stops one verifier from being inconsistent with itself), so multiple verifiers reaching different conclusions is a real, reachable state — an audit found the original at-least-one rule let a single verified grant the boost regardless of how many independent verifiers rejected the same receipt, a real exploit for the receipt's own parties (get one colluding or careless verifier to say verified), not merely an unbuilt feature. This is a narrow anti-exploit tiebreak, not the multi-verifier consensus mechanism §12.7 still defers to v0.2 — no verifier-trust weighting, no quorum, no new state, and the common single-verifier case (verified, zero rejected) is unaffected.
A registry MUST additionally report an attestedReceipts count in the reputation response components (§5.3) — the number of an agent's finalized receipts whose Verification evidence nets out to verified per the above — so the boost is auditable rather than folded invisibly into trustScore, same principle as every other component.
12.6 REST API additions
| Method & path | Description |
|---|---|
POST /verifications (signed, caller = verifier) |
Submit a signed verification of a finalized receipt (§12.3). |
GET /verifications/:id |
Fetch a single verification. |
GET /receipts/:id/verifications |
List verifications referencing a receipt. RECEIPT_NOT_VISIBLE (403) applies the same as GET /receipts/:id (§4.4). |
POST /agents/:id/verifier-status (signed, caller = operator only) (v0.10) |
Grant or revoke :id's isAuthorizedVerifier flag (§2, §12.3 rule 4). Body: { "authorized": true | false }. Response 200 with the full updated agent record. |
New error codes: RECEIPT_NOT_FINALIZED, NOT_VERIFIER (the request isn't signed by the verifier it names), SELF_VERIFICATION (as of v0.6, either party to the receipt — not just the provider), VERIFICATION_TARGET_MISMATCH, UNSUPPORTED_VERIFICATION_METHOD, INVALID_VERIFICATION_SIGNATURE, DUPLICATE_VERIFICATION, VERIFICATION_NOT_FOUND, VERIFIER_ALREADY_DECIDED (v0.6), VERIFIER_NOT_AUTHORIZED (v0.10 — registered but not operator-authorized, §12.3 rule 4), NOT_OPERATOR (v0.10 — caller of POST /agents/:id/verifier-status is not the registry's configured operator identity). AGENT_NOT_FOUND (already defined for other resources, §2) now also applies here (v0.6): an unregistered verifier.
The operator identity itself is deployment configuration, not a protocol-defined identity. A registry MUST designate exactly one did:key as its operator (reference implementation: INAM_OPERATOR_DID env var, Node; OPERATOR_DID binding, Worker) and MUST treat it as unset by default — a registry with no operator configured MUST reject every POST /agents/:id/verifier-status call with NOT_OPERATOR (nobody is authorized to authorize) rather than falling open. This spec does not define operator succession, multi-operator setups, or key rotation for the operator identity itself — a single static operator key is the deliberately narrow v0.10 scope; anything beyond that is future work, same spirit as §12.7's other deferrals.
12.7 Explicitly deferred (v0.2 backlog)
Not omitted by accident — deferred because shipping them alongside v0.1 would mean testing multiple new state spaces at once instead of proving one solid primitive first:
- Multi-verifier consensus (
min_verifiers, agreement-threshold policy) — v0.1 is exactly one verifier per verification; a job/receipt needing stronger assurance can accumulate multiple independentPOST /verificationscalls from different verifiers today (nothing prevents that), but a registry has no obligation to compute consensus across them yet. human_attestation/external_attestationmethods — including any passthrough for another system's own attestation (OpenWork, AgentPass, a cloud provider, etc.) verifying the work instead of an INAM-native verifier.- Verifier-side reputation — a verifier accumulating its own track record (agreement rate with eventual disputes, volume, etc.) as a second reputation dimension distinct from provider reputation.
- Auto-dispute on rejection — a
rejectedVerification automatically opening a receipt dispute rather than sitting as inert evidence (§12.4).
None of the above are needed for a registry to conform to this version. What is not deferred, unlike the list above: cross-runtime interop is a first-class v0.1 requirement, same as §8 — a verification signed by any conforming SDK in any language MUST verify identically against any conforming runtime. This is the acceptance bar that makes §12 a real protocol addition rather than one implementation's local feature: Node-created → Worker-verified, Worker-created → Python-verified, Python-created → Node-verified, all three producing byte-identical verificationIds for the same content.
12.8 Where verification compute runs (v0.16)
The check that produces a verified / rejected judgment — running the deterministic test, or having an agent examine the work — happens in the verifier's own environment, before it signs. INAM defines the attestation format (§12.1), the content-addressing (§12.2), and the trust rules (§12.3–§12.5); it does not define, provide, or operate an environment to run the check in. This is the same boundary as §0's "not an agent runtime," stated explicitly for verification because §12's method names (deterministic, agent_attestation) could otherwise be read as implying a registry-side executor.
A registry MUST NOT run a Verification's underlying check on a verifier's behalf. On POST /verifications a registry does exactly what §12.3 lists — validate the signature, the operator's verifier grant, the outputHash match against the referenced receipt, the one-decision-per-verifier rule — and records whatever result the verifier signed (§12.3: "a registry MUST NOT substitute its own judgment for the verifier's"). It never fetches outputUri, never re-runs a test, never inspects the work. The registry's cost per verification is one signature check; the compute cost of deciding verified vs rejected is entirely the verifier's.
Two deployment shapes are legitimate and a registry treats them identically (it cannot tell them apart — both just arrive as a signed POST /verifications):
- Inline — the verifier runs the check inside its own agent runtime (the same process that holds its key), then signs and submits. Simplest; appropriate when the check is cheap and the verifier is already a running agent.
- Self-deployed service — the verifier runs the check as a separate service it operates itself (its own worker, edge function, CI job, TEE, …), which then signs with the verifier's key and submits. Appropriate when the check is expensive, needs isolation, or is shared across many verifications. The service is the verifier's infrastructure, not the registry's — a registry offering to host it would be operating a verification runtime, which §10 puts out of scope.
scripts/integrity-verifier.ts (v0.33) is a real shape-2 verifier: the one the protocol maintainers run against the live registry, hourly, from GitHub Actions under its own operator-granted key. It checks output integrity only (the bytes at outputUri hash to outputHash) and says so in its registry profile. verified from it means "the signed output exists and is intact", not "the output is correct". It has a reject-only mode that never signs verified.
examples/reference-verifier.ts is a runnable shape-1 verifier: it fetches a finalized receipt (an unsigned read), runs its deterministic output-hash check locally, and signs a Verification — with the compute boundary called out at each step.
13. Transparency log (v0.30)
Receipt-lifecycle records (§4, §12) are stored as mutable rows: countersign, openDispute, and resolveDispute each overwrite the receipt's existing row/key in place. Nothing outside the registry's own database can detect a row being retroactively edited between two reads — a compromised or dishonest operator could rewrite history with no external trace. This section adds an append-only, independently-verifiable log alongside that mutable state, following the RFC 6962 (Certificate Transparency) Merkle tree design.
13.1 What gets logged
One leaf per receipt-lifecycle event:
receipt_finalized— onPOST /receipts/:id/countersign(§4.2).dispute_opened— onPOST /receipts/:id/dispute(§4.3).dispute_resolved— onPOST /receipts/:id/dispute/resolve(§4.3).nonperformance_reported— onPOST /jobs/:id/report-nonperformance(§3.3).
A registry MUST append exactly one leaf for each of these events, in the order they're applied, and MUST NOT append a leaf for an event that failed validation (a rejected countersign, a dispute that failed its window check, etc.). Job postings and offers are out of scope — the Execution Receipt, not the Job resource, is this protocol's trust-bearing artifact (§4).
Each leaf's underlying entry is a canonical JSON object { entryType, refId, timestamp, dataHash } (v0.34). refId is the receiptId for the three receipt events and the jobId for nonperformance_reported. dataHash is "sha256:" + hex(SHA256(canonical JSON of the event's payload)), where the payload is the finalized receipt, the dispute object, or the non-performance record. The leaf hash is computed over this entry's canonical-JSON bytes, so the leaf commits to the payload without containing it. payloadHash() in sdk-js/src/core/transparencyLog.ts is the reference. A registry MUST store the payload beside the leaf (§13.3) and MUST NOT publish it for a participants_only receipt (§4.4). Entries appended before v0.34 embed the payload directly as data in place of dataHash. Verifiers MUST accept both shapes, and each is checked the same way: re-hash the entry bytes and compare them to the leaf.
13.2 Hashing
RFC 6962 §2.1 domain separation — a leaf hash and an internal node hash MUST use different single-byte prefixes before hashing, so a malicious operator cannot pass off an internal node as a leaf (or vice versa) to fabricate a proof:
- Leaf hash:
SHA256(0x00 || entry_bytes). - Internal node hash:
SHA256(0x01 || left_child_hash || right_child_hash). - Empty-tree root:
SHA256("")(the standard RFC 6962 convention for a zero-leaf tree).
Root, inclusion-proof (PATH), and consistency-proof (PROOF) construction otherwise follow RFC 6962 §2.1.1/§2.1.2 exactly — sdk-js/src/core/merkleLog.ts is the reference implementation both runtimes import; a registry in another language MUST produce byte-identical root hashes and proofs for the same leaf sequence.
13.3 Storage
A registry MUST store the log as a single append-only table (leaf_index as a dense, zero-based, gapless primary key; entry_type; ref_id; created_at; data, the canonical entry bytes; leaf_hash; payload, nullable, v0.34) and MUST NOT update or delete an existing row, except that an operator MAY set payload to NULL to erase it. The leaf and every proof are unaffected, and the entry's dataHash still lets anyone holding the original payload prove it was what the log recorded. payload is NULL from the start for a participants_only receipt (the parties hold their own copy) and for pre-v0.34 entries, whose payload is already inside data. leaf_index order is append order — it is the log's own timeline, independent of created_at. Root/proof computation is a pure function of the ordered leaf_hash column, recomputed on demand rather than cached as a persisted node structure — O(n) per request over a registry's own event volume, deliberately not the frontier-persisting optimization real CT logs use at much larger scale (an upgrade path, not a conformance requirement, if a registry's log volume ever makes on-demand recomputation a measurable cost).
13.4 REST API additions
See §6's table (GET /transparency/sth, /entries, /proof/inclusion, /proof/consistency). All four are unsigned, public reads — the log is meant to be checkable by anyone, not just the receipt's own parties.
GET /transparency/sth returns the registry's current Signed Tree Head — { treeSize, rootHash, timestamp } — unsigned. This is a deliberate departure from Certificate Transparency's own convention (a CT log signs its STH with a dedicated log key). INAM's operator identity (§12.6) authorizes trust roles on other agents; it is not a registry-operated signing key over the registry's own state, and inventing one for this purpose alone would be a second, narrower trust primitive with no other use in the protocol. Instead, the tamper-evidence guarantee comes from consistency proofs between two client-observed STHs: a caller that fetches the STH periodically (or a third-party monitor that does so on callers' behalf) and later requests GET /transparency/proof/consistency?first=<old treeSize>&second=<new treeSize> can independently confirm the log at the old size is a true prefix of the log at the new size — any retroactive edit, reorder, or deletion of a past leaf makes that proof fail to verify, regardless of who served the STH or over what transport. scripts/sth-monitor.ts (v0.33) is that monitor: it keeps every STH it observes, requires each new one to be a verified consistency extension of the last (and an identical root at an unchanged size), and re-hashes and inclusion-checks every new entry. The maintainers run it hourly and publish its history on the repository's monitor-state branch. Each new tree head is also timestamped in the Bitcoin blockchain through OpenTimestamps (anchors/<treeSize>.json and its .ots proof on the same branch; check with ots verify), so the head's existence at that time no longer rests on the maintainers' or GitHub's word. It is read-only and meant to be run by anyone else too, since a monitor operated only by the registry's own maintainers is weaker evidence than one operated independently.
GET /transparency/proof/inclusion?leafIndex=&treeSize= and GET /transparency/proof/consistency?first=&second= MUST reject an out-of-range index or tree size with INVALID_LEAF_INDEX / INVALID_TREE_SIZE (400) rather than silently clamping.
13.5 SDK support
Both sdk-js and sdk-python expose the pure verification functions (verifyInclusion/verifyConsistency in sdk-js/src/core/merkleLog.ts; verify_inclusion/verify_consistency in sdk-python/inamprotocol/merkle_log.py, a 1:1 port validated against TypeScript-generated vectors, §8) so a caller can check a proof itself without trusting the registry's own arithmetic. Proof generation is server-side only in both reference implementations — an SDK is a verifier of the registry's log, not a second producer of it.
Deliberately out of scope for this version: a monitor/gossip client that periodically polls a registry's STH, retains history, and alerts on inconsistency. That's real third-party-operated infrastructure (the same role Certificate Transparency's independent monitors play) — this spec defines the primitives a monitor would use (§13.4's endpoints, §13.5's verification functions), not an implementation of one. A registry MUST NOT need a companion monitor running to conform to this version; the proof endpoints alone let any individual caller verify inclusion/consistency for its own receipts on demand.