Skip to content

[AI assisted] Plan G core: AnchorResolver abstraction + FQDN/did:web/LEI profiles#15

Open
scourtney-godaddy wants to merge 5 commits into
feat/plan-f-base-onlyfrom
feat/plan-g-anchor-core
Open

[AI assisted] Plan G core: AnchorResolver abstraction + FQDN/did:web/LEI profiles#15
scourtney-godaddy wants to merge 5 commits into
feat/plan-f-base-onlyfrom
feat/plan-g-anchor-core

Conversation

@scourtney-godaddy
Copy link
Copy Markdown

@scourtney-godaddy scourtney-godaddy commented May 16, 2026

Introduces the AnchorResolver interface so the RA can use something other than the FQDN as the agent's cryptographic identity anchor. Every registration still carries an FQDN as agentHost (the operational endpoint where the agent terminates TLS); what changes is what serves as the identity anchor. The existing FQDN path lifts behind the interface; two new resolvers land alongside it: did:web and LEI. No wire changes yet; that is the next PR.


The first of three Plan G PRs. This one adds the ANS-0 Identity
Anchor abstraction the proposal at
docs/proposals/2026-05-16-spec-skeletons/ans-0-identity-anchor.md
specifies, plus two concrete anchor-profile resolvers (FQDN, DID
via did:web, LEI), and the registry facade that dispatches inputs
to the right resolver by lexical form. No service or handler
changes yet; those land in the next PR in the stack. Two more DID
methods (did:key, did:pkh) plus real-world LEI vectors land in
the third PR in the stack.

Domain types:

  • domain.AnchorType enum: fqdn, did, lei. Closed; a fourth
    top-level type is an ANS-0 amendment, not a profile addition.
  • domain.IdentityClaim carries the typed result of resolution:
    anchor type, canonical resolved ID, public key in JWK form,
    optional metadata URL pointer, IssuedAt + ExpiresAt for cache
    bounds. The struct shape matches the TypeScript signature in
    the architectural-index skeleton §3.1.

Port:

  • port.AnchorResolver is the contract higher-spec code reads
    identity through: Resolve(ctx, input) → (*IdentityClaim, error)
    plus SupportedProfiles() []string for configuration audit.

Adapters under internal/adapter/anchor/:

  • fqdn: lifts the FQDN canonicalization and label validation
    that previously lived inline in the registration service. RFC
    1123 hostname constraints, lowercase + trailing-dot strip,
    rejection of underscore-prefixed labels (ANS-3 reserves those
    for registry records).
  • did: implements did:web resolution. HTTPS GET against
    /.well-known/did.json (or the path-component variant for
    scoped DIDs), DID document parsing, verification method
    selection (assertionMethod first, then authentication, then
    any verificationMethod entry; tie-breaker on the most recent
    updated/created), JWK conversion (publicKeyJwk passes
    through; publicKeyMultibase and publicKeyPem return typed
    not-implemented errors flagging the migration boundary).
    Cross-domain redirects fail closed with
    DID_REDIRECT_DOMAIN_MISMATCH.
  • lei: ISO 17442 mod-97 (ISO 7064 MOD 97-10) check digits
    in pure Go, plus a GLEIFClient interface stub. A production
    GLEIF API client lands when an actual GLEIF testbed is wired
    into CI; today the resolver is useful for testbeds and unit
    tests with no network call (Resolve returns
    LEI_GLEIF_NOT_CONFIGURED if no client is injected).
  • registry: dispatches by lexical form per ANS-0 §4.1.
    did: prefix → DID branch; 20-char ASCII alphanumeric → LEI
    branch; RFC 1123 hostname with at least one dot → FQDN branch;
    anything else → INVALID_ANCHOR_FORMAT. Configuration-driven:
    unconfigured branches return PROFILE_NOT_CONFIGURED so the
    startup validator catches deployment shape errors before the
    first registration request.

Tests:

  • FQDN canonicalization edge cases (empty, single-label, too
    long, embedded whitespace, empty/oversize labels, hyphen
    edges, underscore rejection, non-LDH characters).
  • did:web happy path against an httptest TLS server plus the
    documented failure modes (404, bad content type, ID mismatch,
    no verification method, missing publicKey* shape, redirect
    policy).
  • LEI canonicalization happy path plus all the LEI_BAD_FORMAT
    and LEI_BAD_CHECK_DIGITS cases. Resolution against an
    injected fake GLEIFClient covers ACTIVE acceptance,
    INACTIVE/LAPSED/RETIRED/MERGED rejection, lookup error
    propagation, missing-attestation-key rejection.
  • Registry dispatch matrix (FQDN, did:web, LEI shape,
    FQDN-shape with a dot beats LEI shape, every reject path,
    PROFILE_NOT_CONFIGURED branches, full SupportedProfiles
    union).
  • All resolvers satisfy the port.AnchorResolver compile-time
    check (var _ port.AnchorResolver = (*Registry)(nil)).

The DNSid mention in the original FQDN package comment was
replaced with the spec's generic external-key-locator framing
per the proposal's update on the same day. The resolver itself
names no specific external discovery effort.

Stacks on #14 (Plan F) → #13 (Plan D) → #12 (Plans A + C).
Merge order: #12#13#14 → this.

Test plan

  • make check (gofmt + golangci-lint + 90% coverage gate)
  • Each resolver has its own unit tests covering happy path + every documented error code
  • Registry facade dispatch matrix (FQDN, did, LEI, unknown shape, profile-not-configured)
  • LEI mod-97 against GLEIF documentation example (round-trip + perturbed-check-digit rejection)
  • No live network calls in CI (the did:web resolver uses httptest; the LEI resolver stubs GLEIFClient)

🤖 Generated with Claude Code

scourtney-godaddy and others added 5 commits May 16, 2026 16:09
Plan G Slice 1: lift FQDN-specific identity handling into a typed
AnchorResolver port + IdentityClaim domain type, behind a per-profile
adapter package. Subsequent slices add did:web (Slice 2), LEI stub
(Slice 3), and the multi-profile registry facade (Slice 4) without
disturbing the existing FQDN registration flow.

Domain:
- AnchorType enum with three values (fqdn, did, lei) matching
  ANS-0 §3 in the proposal at docs/proposals/2026-05-16-spec-skeletons/
  ans-0-identity-anchor.md. The enum is intentionally closed: a fourth
  top-level type is an ANS-0 amendment, not a profile addition.
- IdentityClaim struct shaped to match the TypeScript signature in
  the proposal's architectural-index skeleton §3.1. PublicKeyJWK as
  []byte rather than a parsed key keeps the package free of a JWK
  library dependency at this layer; downstream consumers parse on
  demand.
- IdentityClaim.Validate() is a defense-in-depth check at API
  boundaries; AnchorResolver implementations are the authoritative
  source for the four ANS-0 §4.2 verification checks.
- IdentityClaim.FQDN() returns the canonical FQDN when the anchor is
  type fqdn, empty otherwise; callers needing the FQDN regardless of
  anchor type continue to read AgentRegistration.AgentHost.

Port:
- AnchorResolver interface with Resolve(ctx, input) and
  SupportedProfiles(). Higher-spec code (RegistrationService,
  VerificationWorker, TrustIndex once those refactor lands) reads
  identity through this interface only.

FQDN adapter:
- internal/adapter/anchor/fqdn implements AnchorResolver for the
  ANS-0 §0.A FQDN profile. Slice 1 keeps the resolver shape-only:
  ResolveWithKey takes a pre-validated public key from the caller
  (registration service) and shapes the IdentityClaim. Resolve
  itself returns FQDN_RESOLVE_NOT_IMPLEMENTED to flag the migration
  boundary; Slice 4 absorbs DNS resolution + cert chain validation
  into this package.
- Canonicalization rules: lowercase, strip trailing dot, RFC 1123
  hostname constraints, label LDH-only, no underscore-prefixed
  labels (ANS-3 reserves _-prefixed names for the registry's own
  records).
- Tests cover canonical happy path, metadata URL, all the malformed-
  format cases (empty, single-label, too-long, whitespace, empty
  label, oversize label, hyphen edges, underscore rejection,
  non-LDH characters), missing public key, and the migration-
  boundary error code.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan G Slice 2: implements ANS-0 §0.B DID anchor profile for did:web
per docs/proposals/2026-05-16-spec-skeletons/anchor-0b-did.md §3.

Resolver pipeline:
  1. Lexical validation: did:web prefix, percent-encoded port
     rejection, consecutive-colon rejection, empty-component
     rejection.
  2. Resolution URL construction: empty path components yield
     /.well-known/did.json; non-empty yield /<comp1>/<comp2>/.../did.json
     with URL-escaped path components.
  3. HTTPS GET with Accept: application/did+json, application/json.
     Redirect policy enforces same-effective-second-level-domain;
     cross-site redirects fail with DID_REDIRECT_DOMAIN_MISMATCH;
     5-hop limit.
  4. Response validation: status 200, content-type one of the two
     accepted, 1MiB body cap.
  5. DID document parsing: id field required, must equal the input
     DID URI canonically.
  6. Verification method selection: assertionMethod first, then
     authentication, then any verificationMethod entry. Within
     candidates, newest updated/created wins; ties broken
     lexicographically by id.
  7. JWK conversion: publicKeyJwk supported (re-canonicalized for
     stable byte form); publicKeyMultibase and publicKeyPem return
     typed not-implemented errors flagging the slice 2.1 boundary.
  8. IdentityClaim shaped with anchorType=did, IssuedAt=now,
     ExpiresAt=now+24h (the freshness budget per profile spec §3.5).

Tests cover:
  - parseDIDWeb canonical happy paths and every documented error
    code.
  - buildResolutionURL for both well-known and path-based shapes,
    plus path-component URL escaping.
  - selectVerificationMethodJWK: assertionMethod precedence,
    authentication fallback, most-recent picking, embedded objects,
    no-method fallback to first verificationMethod entry.
  - verificationMethodToJWK unsupported-shape paths (multibase,
    pem, missing, malformed JWK).
  - sameEffectiveDomain across same-site, cross-domain, ports.
  - Resolve happy path against an httptest TLS server, with a
    routing client transport that maps did:web URLs to the test
    server's loopback listener.
  - Resolve failure modes: 404, bad content type, malformed JSON,
    missing id, ID mismatch, no verification method.
  - Redirect policy: 5-hop limit and cross-domain rejection;
    same-site (last two labels) admitted.

Coverage holds at 90.1%. publicKeyMultibase and publicKeyPem
decoding are deferred to Slice 2.1; the typed errors keep callers
on the JWK path until then.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replace stale Slice 1 reference to DNSid with the generic external
key-locator framing the proposal now uses, matching the rewritten
anchor-0a-fqdn.md §3.4. The resolver is structured to admit
adapters for any parallel agentic-discovery convention; ANS does
not name a specific external spec normatively in either the spec
or the reference implementation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan G Slice 3: implements ANS-0 §0.C LEI anchor profile per
docs/profiles/anchor-0c-lei.md. The resolver carries the format
validation that is portable across deployments (no GLEIF API
access required) and stubs the actual GLEIF resolution behind a
GLEIFClient interface.

Format validation is fully implemented:
- 20 ASCII alphanumeric characters, uppercase canonicalized.
- ISO 17442 §5.1 mod-97 check digit verification using the
  ISO 7064 MOD 97-10 algorithm with letter-pair expansion (A=10,
  ..., Z=35).
- The well-known GLEIF documentation example LEI
  (529900T8BM49AURSDO55) passes; perturbed check digits fail.

GLEIF resolution is behind a Resolver.WithClient injection:
- A Resolver constructed via New() (no client) returns
  LEI_GLEIF_NOT_CONFIGURED on Resolve, surfacing the slice
  boundary clearly. Format validation still runs first so callers
  testing against malformed LEIs get the same error as a fully-
  configured deployment.
- A Resolver.WithClient(c) where c implements GLEIFClient runs
  the full pipeline: lookup, status check (only ACTIVE admitted;
  INACTIVE/LAPSED/RETIRED/MERGED rejected), attestation-key
  presence check, IdentityClaim construction with
  ExpiresAt = IssuedAt + 7 days (the freshness budget per profile
  spec §3.5).

GLEIFClient is defined in this package as a minimal interface so
the production HTTP client can land in a follow-up slice without
touching the resolver. The LookupRecord shape (LEI, EntityName,
EntityStatus, Jurisdiction, AttestationJWK, UpdatedAt) carries
exactly what the resolver needs; richer fields (parent/child
relationships, registration history) belong to a thicker client
that composes with this one.

Tests cover:
- Canonicalize happy path, lowercase->uppercase, whitespace trim.
- LEI_BAD_FORMAT for empty, too-short, too-long, hyphen,
  embedded space, non-alphanumeric.
- LEI_BAD_CHECK_DIGITS for perturbed check digits.
- LEI_GLEIF_NOT_CONFIGURED when no client injected.
- LEI_RESOLUTION_FAILED on client error.
- LEI_UNKNOWN on nil record.
- LEI_INACTIVE for each non-ACTIVE status (parametric).
- LEI_NO_ATTESTATION_KEY when AttestationJWK is empty.
- Happy path with deterministic clock + injected fake client.

Coverage holds at 90.2%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan G Slice 4: composes the per-profile resolvers (FQDN, did:web,
LEI) behind a single AnchorResolver facade that the registration
service plumbs to. Lexical-form dispatch per ANS-0 §4.1: "did:"
prefix routes to DID, 20-char ASCII alphanumeric routes to LEI,
hostname shape with at least one dot routes to FQDN, anything else
fails INVALID_ANCHOR_FORMAT.

Composition is configuration-driven: deployments register the
resolvers their accepted-profile list admits. An empty registry
rejects every input cleanly; a partial registry (e.g., FQDN-only)
returns PROFILE_NOT_CONFIGURED on inputs whose dispatched branch
has no registered resolver. The configuration validator at startup
audits Registry.SupportedProfiles() against the deployment's
accepted list before the first registration request lands.

The Registry uses a function-typed FQDN seam (fqdnShapeResolver)
so the slice-4 commit composes with the FQDN package's current
Resolve stub. Slice 5 absorbs DNS resolution into the FQDN
package; both surfaces stay aligned because the seam type matches
port.AnchorResolver's Resolve method.

Tests:
- Lexical dispatch matrix: FQDN, did:web (lowercase + uppercase
  prefix), LEI (20-char alphanumeric), FQDN-shape edge case where
  a 20-char hostname with a dot beats the LEI shape.
- INVALID_ANCHOR_FORMAT for empty input plus five categories of
  non-matching shapes.
- PROFILE_NOT_CONFIGURED when the dispatched branch has no
  registered resolver, parametric across all three branches.
- SupportedProfiles union when all three branches are registered.
- End-to-end LEI dispatch with an injected fake GLEIF client
  proving the composition produces a clean IdentityClaim.
- Lexical helpers (looksLikeLEI, looksLikeFQDN) tested
  independently for the shape boundaries.

Compile-time check that *Registry satisfies port.AnchorResolver
lives in the test file; the production package's import graph
stays free of port to avoid an import-cycle risk if the port
package ever needs an anchor type.

Coverage holds at 90.3%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

AI assisted Pull request created with AI assistance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant