logicsrc/docs/ans-sdk.md
Anthony Ettinger 0ae24719e3 docs(ans): spec the @logicsrc/ans TypeScript SDK
Design doc for the LogicSRC TS SDK for the Agent Name Service (ANS): resolver +
offline transparency-log verifier + registration client + coinpay-DID bridge.
Covers scope, package layout, full API surface, the ans-verify algorithm,
milestones (M1 verifier -> M2 register -> M3 DID bridge), and risks.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 14:41:00 +00:00

15 KiB
Raw Permalink Blame History

ANS SDK (@logicsrc/ans)

@logicsrc/ans is the LogicSRC TypeScript SDK for the Agent Name Service (ANS): the JS/TS client + offline verifier for resolving an agent name to a verifiable, versioned identity.

ANS is "DNS for agents": where DNS resolves a domain to an address, ANS resolves an agent name (ans://v1.0.0.my-agent.example.com) to a cryptographic identity, anchored to domain ownership (DNS/ACME) and backed by a private CA plus an append-only transparency log (SCITT/COSE receipts, RFC 9162 / RFC 6962).

Upstream ANS ships SDKs for Go, Java, and Rust — but not JavaScript/TypeScript, which is the language of the agent/MCP/web ecosystem (LogicSRC, sh1pt, AgentBBS). This SDK fills that gap and makes ANS a first-class identity source alongside the existing LogicSRC DID model.

Why this lives in LogicSRC

  • No upstream TS SDK. Go/Java/Rust only. This is a clean, reusable OSS artifact and a first-mover contribution.
  • LogicSRC is already an identity layer. Identity here is a DID (via the coinpay plugin's did.auth). ANS is the naming + domain-anchored verification layer that DIDs lack — they are complementary, not competing (see DID bridge).
  • Multiple in-house consumers. AgentGit members, the sh1pt registry-ans ship target, AgentBBS join-time verification, and commandboard discovery can all consume one SDK.

The split, stated once:

  • ANS answers: what is this agent's canonical name, is it really it, and which version/endpoint? — discovery + domain-anchored verification.
  • LogicSRC DID answers: is this a portable identity I can authorize, pay, and score? — sovereign identity + reputation + payment rails.

Scope

In scope for the SDK:

  1. Resolverans:// name → AnsIdentity (cert chain, endpoint, version, lifecycle events) via the registry HTTP API.
  2. Offline verifier — cryptographically verify a resolution against the transparency log without trusting the operator beyond advertised root keys. This is the hard, high-value part and ports the ans-verify semantics to TS.
  3. Registration client — open a registration, drive a domain-ownership challenge (DNS-01 / ACME), and read back the issued identity + receipt.
  4. DID bridge — map between an ans:// name and a LogicSRC/coinpay DID.

Out of scope (delegated, not reimplemented):

  • Running a registry or transparency log (that's the upstream Go ans server).
  • DNS record application — delegated to a DNS provider. In sh1pt that's its DNS adapters; in LogicSRC the caller supplies a DnsApplier (see Registration).
  • Certificate issuance / the private CA (server-side).

Architecture

        @logicsrc/ans  (this package)
   ┌──────────────────────────────────────────┐
   │  AnsClient                                 │
   │   ├─ resolve(name)      ── registry HTTP ──┼──► ANS registry
   │   ├─ register(req)      ── registry HTTP ──┼──►  (Go `ans` server)
   │   └─ rootKeys()         ── registry HTTP ──┘
   │                                            │
   │  Verifier (offline, pure)                  │
   │   ├─ verifyReceipt(receipt, rootKeys)      │  COSE_Sign1 + Merkle proof
   │   └─ verifyResolution(identity, opts)      │  (no network)
   │                                            │
   │  DidBridge                                 │
   │   ├─ ansNameForDid(did)                    │
   │   └─ didForAnsName(name)                   │
   └──────────────────────────────────────────┘
        ▲                         ▲
        │ consumed by             │
   sh1pt registry-ans      AgentGit / AgentBBS / commandboard

The Verifier is pure and dependency-light (crypto + CBOR/COSE only, no fetch), so it runs in Node, Deno, Bun, edge runtimes, and the browser, and is trivially unit-testable with fixtures captured from the upstream Go server.

Capabilities

name.resolve          resolve an ans:// name to an identity
name.verify           offline-verify a resolution against the transparency log
name.register         open a registration + domain-ownership challenge
name.status           poll a pending registration / verification
receipt.verify        verify a SCITT COSE_Sign1 inclusion receipt
rootkeys.fetch        fetch + parse the registry root-keys (sumdb-note)
did.bind              bind an ans:// name to a LogicSRC DID
did.resolve           resolve a DID to its ans:// name (and back)

Package layout

packages/ans/
  package.json            @logicsrc/ans  (ESM, tsc build, vitest)
  tsconfig.json           extends ../../tsconfig.base.json
  src/
    index.ts              public exports
    types.ts              AnsName, AnsIdentity, Receipt, RootKeys, …
    name.ts               parse/format ans:// names (zod-validated)
    client.ts             AnsClient — registry HTTP (resolve/register/status)
    verify/
      receipt.ts          COSE_Sign1 parse + ES256 verify
      merkle.ts           RFC 6962 leaf hash + inclusion-proof walk
      rootkeys.ts         sumdb-note root-keys parser + kid→key map
      index.ts            verifyReceipt(), verifyResolution()
    did.ts                DidBridge (ANS ↔ coinpay DID)
    index.test.ts         unit tests (fixtures/ from upstream Go server)
  fixtures/               captured receipts, root-keys, resolutions

@logicsrc/ans is a leaf package (like @logicsrc/sdk): it depends only on crypto/CBOR libraries and @logicsrc/schemas for shared types. The coinpay DID coupling stays behind a small injected interface so the verifier core has no LogicSRC dependency and could be published standalone.

Public API (TypeScript surface)

// ── names ───────────────────────────────────────────────────────────
/** ans://v<semver>.<agent>.<domain> */
export interface AnsName {
  raw: string;          // "ans://v1.0.0.my-agent.example.com"
  version: string;      // "1.0.0"
  agent: string;        // "my-agent"
  domain: string;       // "example.com"
}
export function parseAnsName(raw: string): AnsName;     // throws on malformed
export function formatAnsName(parts: Omit<AnsName, 'raw'>): string;

// ── identity / receipts ─────────────────────────────────────────────
export interface AnsIdentity {
  name: AnsName;
  endpoint?: string;             // advertised agent endpoint
  capabilities: string[];
  certChainPem: string;          // identity cert (private-CA signed, mTLS)
  serverCertTlsa?: string;       // optional BYOC pinned TLSA
  events: LifecycleEvent[];      // from the transparency log
  receipt: Receipt;              // SCITT COSE_Sign1 inclusion receipt
}
export interface LifecycleEvent { type: string; at: string; payload?: unknown; }
export interface Receipt { cbor: Uint8Array; }   // raw COSE_Sign1 bytes
export interface RootKeys { keys: Map<string /*4-byte kid hex*/, CryptoKey>; }

// ── client (network) ────────────────────────────────────────────────
export interface AnsClientOptions {
  registryUrl: string;                 // e.g. https://registry.ans.dev
  token?: string;                      // for register/status
  pinnedRootKeysPem?: string;          // skip /root-keys; trust this instead
  fetch?: typeof fetch;                // injectable for tests/edge
}
export class AnsClient {
  constructor(opts: AnsClientOptions);
  resolve(name: string | AnsName): Promise<AnsIdentity>;
  rootKeys(): Promise<RootKeys>;
  register(req: RegisterRequest): Promise<Registration>;
  status(name: string | AnsName): Promise<RegistrationStatus>;
}

// ── verifier (offline, pure, no network) ────────────────────────────
export interface VerifyOptions { rootKeys: RootKeys; now?: Date; }
export interface VerifyResult { ok: boolean; reason?: string; rootHashHex: string; }
export function verifyReceipt(receipt: Receipt, opts: VerifyOptions): Promise<VerifyResult>;
export function verifyResolution(id: AnsIdentity, opts: VerifyOptions): Promise<VerifyResult>;

// ── registration ────────────────────────────────────────────────────
export interface RegisterRequest {
  agent: string; domain: string; version: string;
  endpoint?: string; capabilities?: string[];
  verify: 'dns' | 'acme';
  dns?: DnsApplier;                    // when set, SDK applies the challenge
}
/** Caller-supplied DNS automation (e.g. a sh1pt DNS adapter). */
export interface DnsApplier {
  upsertTxt(record: { name: string; value: string }): Promise<void>;
}
export interface Registration { name: AnsName; challenge: { type: 'TXT'; name: string; value: string }; }
export interface RegistrationStatus { state: 'pending' | 'verifying' | 'live' | 'failed'; message?: string; }

// ── DID bridge ──────────────────────────────────────────────────────
export interface DidBridge {
  ansNameForDid(did: string): Promise<AnsName | null>;
  didForAnsName(name: string | AnsName): Promise<string | null>;
}

Usage sketches

// Resolve + verify (the common path; trustless)
const ans = new AnsClient({ registryUrl: 'https://registry.ans.dev' });
const id = await ans.resolve('ans://v1.0.0.my-agent.example.com');
const { ok } = await verifyResolution(id, { rootKeys: await ans.rootKeys() });
if (!ok) throw new Error('unverified agent identity');

// Register with automated DNS (DNS applier supplied by the caller, e.g. sh1pt)
const reg = await ans.register({
  agent: 'my-agent', domain: 'example.com', version: '1.0.0',
  endpoint: 'https://my-agent.example.com', verify: 'dns',
  dns: { upsertTxt: ({ name, value }) => dnsAdapter.upsertTxt(name, value) },
});

Verification algorithm

Ports the upstream ans-verify flow. Pure functions over bytes; the only trust input is the root keys (fetched once, or pinned):

  1. Obtain root keys: parse /root-keys (sumdb-note format) or the pinned PEM. Build a kid (4-byte) → verifier key map.
  2. Parse the receipt as COSE_Sign1 (RFC 8152 tag 18, ES256).
  3. Extract the Merkle inclusion proof + leaf payload from the protected/unprotected headers.
  4. Compute the leaf hash via RFC 6962: SHA-256(0x00 || payload).
  5. Walk the Merkle path from the leaf hash to the claimed root hash.
  6. ES256-verify the COSE Sig_structure signature using the kid-mapped key.
  7. Cross-check leaf-hash consistency and the resolved identity binding (name, cert, lifecycle).

verifyResolution() wires the resolved AnsIdentity through steps 27 and also checks the cert chain binds to the resolved domain.

DID bridge

The bridge is where ANS and the LogicSRC/coinpay DID model meet — directly the "CoinPay DID ↔ ANS" question.

  • did:web under a verified ANS domain. Once a name's domain is ANS-verified, the agent's coinpay DID can be published as did:web:<domain>:<agent> and resolved from the same anchor. ANS provides the discoverable human-readable name + transparency proof; the DID provides the portable identity + reputation receipts + payment rails.
  • Binding direction. did.bind records the ans:// ↔ DID mapping (as an ANS lifecycle event and/or a coinpay DID service entry). DidBridge reads it both ways so AgentGit can keep authenticating with a DID while exposing a verifiable ANS name to the outside world.
  • No DID minting here. The SDK never issues DIDs (that's coinpay) and never issues ANS certs (that's the registry). It only binds and resolves.

Milestones

  • M1 — Resolver + offline verifier. parseAnsName, AnsClient.resolve, rootKeys, verifyReceipt/verifyResolution, fixtures from the upstream Go server. This is the standalone-publishable core and unblocks read-side consumers.
  • M2 — Registration client. register/status + the DnsApplier hook. Lets the sh1pt registry-ans target complete its TODO(M2) (apply challenge, verify, poll receipt) by delegating to this SDK instead of hand-rolled fetch.
  • M3 — DID bridge. DidBridge + did:web publication under the verified domain, wired through the coinpay plugin. Gate on M1+M2 and on how far the upstream IETF draft has stabilized.

Dependencies & testing

  • Crypto/CBOR: prefer WebCrypto (crypto.subtle, ES256) for portability; a minimal COSE/CBOR decoder (e.g. cbor-x or a vendored decoder) for COSE_Sign1. Keep the verifier free of Node-only APIs so it runs on edge and in the browser.
  • Validation: zod for name + wire-shape parsing (matches LogicSRC schema conventions; consider emitting the shapes into @logicsrc/schemas).
  • Tests: capture real /root-keys, resolutions, and receipts from a local upstream ans server into fixtures/; unit-test the verifier against them (happy path + tampered-payload, wrong-kid, bad-proof, expired-cert negatives). Verifier is vitest run src like the other packages, with no network.

Risks / open questions

  • Draft-stage standard. ANS wire formats (receipt headers, root-keys note) may shift; pin to a server commit for fixtures and version the SDK against it.
  • More centralized than DIDs. ANS uses a registry + private CA. Treat ANS as naming/discovery/verification and keep sovereign identity in the DID layer; do not let ANS become the system of record for identity.
  • CBOR/COSE surface in TS. No single blessed lib; the verifier's COSE_Sign1 handling is the main implementation risk — keep it small, vendored if needed, and fixture-driven.
  • Trust bootstrap. pinnedRootKeysPem vs /root-keys is a real trust decision; default to pinning for in-house consumers (AgentGit/AgentBBS) and document the TOFU tradeoff for /root-keys.

First consumers

  • sh1pt registry-ans target — replaces its hand-rolled fetch register call and completes M2 verification by depending on @logicsrc/ans.
  • AgentGit / AgentBBS — verify an agent's ans:// name at join/merge time alongside the existing DID auth.
  • commandboard discovery — resolve + verify advertised agent endpoints.