Skip to content

[AI assisted] Plan G integration: anchor block on V2 register, persist + surface#16

Open
scourtney-godaddy wants to merge 3 commits into
feat/plan-g-anchor-corefrom
feat/plan-g-anchor-integration
Open

[AI assisted] Plan G integration: anchor block on V2 register, persist + surface#16
scourtney-godaddy wants to merge 3 commits into
feat/plan-g-anchor-corefrom
feat/plan-g-anchor-integration

Conversation

@scourtney-godaddy
Copy link
Copy Markdown

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

Wires the AnchorResolver into the V2 register handler and the storage layer. A registration now carries an optional anchor block; the V2 list endpoint surfaces anchorType and anchorResolvedId per row. Plan F's base-only path is the carrier for any non-FQDN anchor.


The second of three Plan G PRs. The previous PR added the
AnchorResolver abstraction and the resolver implementations
(FQDN, did:web, LEI). This PR plumbs the resulting IdentityClaim
through the registration service, persists it on the registration
row, and surfaces it on V2 list and detail responses. The V2
register endpoint accepts an optional anchor block on the wire.

Domain layer:

  • AgentRegistration gains an AnchorClaim *IdentityClaim
    field. Nil for legacy registrations where the identity is
    implicit in AgentHost; populated when the V2 register call
    carried an explicit anchor block.
  • NewRegistration accepts the claim as a new parameter.
    Non-FQDN claims (DID, LEI) carrying a non-zero ANSName or
    non-nil identity CSR fail with
    NON_FQDN_REQUIRES_BASE_ONLY. Until ANS-2 admits non-FQDN
    URI SANs, DID and LEI registrations must take the base-only
    path.

Service layer:

  • RegisterRequest gains an AnchorClaim field; the service
    passes it through to NewRegistration verbatim.
  • The base-only uniqueness check is now scoped by
    (host, anchor_type) rather than host alone (this fix
    ships in the third Plan G PR; this PR carries the field but
    not the scoping change).

Storage layer:

  • Migration 009 adds anchor_type and anchor_resolved_id
    (both nullable) to agent_registrations. Pre-Plan-G rows
    load with both empty.
  • Save writes the columns through a new anchorClaimColumns
    helper. toDomain rehydrates AnchorClaim from the columns,
    leaving PublicKeyJWK empty (verifiers re-resolve through
    the AnchorResolver to honor each profile's freshness budget,
    rather than trusting a stored value).
  • A round-trip test pins the FQDN/DID/LEI/no-claim cases.

Handler layer:

  • registrationRequest gains an optional anchor block:
    { "anchorType": "fqdn"|"did"|"lei", "input": "..." }. Empty
    block omitted from the request signals the legacy
    FQDN-implicit path.
  • resolveAnchorClaim validates the wire shape: anchorType
    must be one of the three values; input must be non-empty;
    the FQDN profile additionally requires input to match
    agentHost case-insensitively (ANCHOR_INPUT_AGENT_HOST_MISMATCH).
  • The V2 list (listItem) and detail (agentDetails) DTOs
    gain anchorType and anchorResolvedId (both omitempty so
    legacy registrations emit nothing). A shared anchorFields
    helper reads from reg.AnchorClaim, returning empty strings
    for nil claims.

The handler-side IdentityClaim carries an empty PublicKeyJWK:
full resolver dispatch (fetching the DID document, calling the
GLEIF API) lands in a follow-up PR. The current shape stores the
anchor type so V2 list and detail responses can surface it,
which is enough for client-side filtering and for downstream
tooling to differentiate FQDN, DID, and LEI registrations.

Stacks on #15 (Plan G core) → #14 (Plan F) → #13#12. Merge
order: #12#13#14#15 → this.

Test plan

  • make check (gofmt + golangci-lint + 90% coverage gate)
  • Domain tests: FQDN claim attaches; DID + versioned rejected; DID + base-only accepted; LEI + base-only accepted
  • Storage round-trip across FQDN/DID/LEI/no-claim cases
  • Handler tests: omitted block returns nil claim; missing/unknown type errors; FQDN agentHost-mismatch rejection; DID and LEI input pass through
  • anchorFields helper for nil/empty/populated claims
  • Reviewer confirms migration 009 applies cleanly

🤖 Generated with Claude Code

scourtney-godaddy and others added 3 commits May 16, 2026 16:09
Plan G Slice 5a: integrates the AnchorResolver-produced
IdentityClaim into the registration flow without changing storage.
Existing FQDN registrations continue to work unchanged (AnchorClaim
is nil); new DID/LEI registrations submit a verified claim through
RegisterRequest.AnchorClaim.

Domain:
- AgentRegistration gains an AnchorClaim *IdentityClaim field. Nil
  for legacy FQDN-only registrations; populated when the caller
  routed through ANS-0's AnchorResolver port.
- NewRegistration accepts the claim as a new parameter. Today it's
  stored verbatim on the aggregate; persistence lands in Slice 5b.
- New domain rule: a non-FQDN AnchorClaim accompanied by a non-zero
  ansName or non-nil identityCSR is rejected with
  NON_FQDN_REQUIRES_BASE_ONLY. Until ANS-2 admits a non-FQDN URI
  SAN, DID/LEI registrations must take the base-only path. This
  keeps the aggregate's invariants coherent: AnchorClaim.AnchorType
  authoritatively selects the registration shape.

Service:
- RegisterRequest gains an AnchorClaim field. The service passes it
  through to NewRegistration verbatim; no other behavior changes.

Tests:
- FQDN claim alongside a versioned registration attaches cleanly
  (the versioned-FQDN path is the dominant case today).
- DID claim with a versioned ansName is rejected with
  NON_FQDN_REQUIRES_BASE_ONLY.
- DID + base-only is accepted; AgentHost (service FQDN where the
  agent is reachable) and AnchorClaim.ResolvedID (did:web URI) are
  intentionally distinct fields.
- LEI + base-only is accepted; the agent's service FQDN remains
  the operational endpoint.

The 4 existing NewRegistration callers were updated to pass nil
for the new parameter. All 168 existing tests continue to pass.
Coverage holds at 90.3%.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan G Slice 5b: persists the IdentityClaim that produced a
registration so the V2 list and detail endpoints can surface anchor
type to clients (Slice 6) and the audit trail records which anchor
profile each registration came in through.

Migration 009 adds two nullable columns to agent_registrations:
- anchor_type: "fqdn", "did", or "lei"
- anchor_resolved_id: canonical FQDN, DID URI, or 20-char LEI

Both nullable so pre-Plan-G rows continue to load (their
AnchorClaim surfaces as nil; the application layer treats nil
as "FQDN-implicit-from-agent_host" for backward compatibility).

PublicKeyJWK is intentionally NOT persisted on the row. ANS-0
verifiers re-resolve the claim through the AnchorResolver on
demand to honor the per-profile freshness budget (1h for FQDN,
24h for did:web, 7d for LEI). A stored stale JWK would defeat
that. Agents whose key has rotated produce a fresh resolution
result the next verification cycle picks up; a verifier that needs
the historical key for an audited event reads it from the ANS-4
Transparency Log entry.

Storage shape:
- Save (insert) writes anchor_type + anchor_resolved_id from
  agent.AnchorClaim through anchorClaimColumns helper.
- Save (update) does NOT write anchor columns: the AnchorClaim is
  fixed at registration time and never mutates through lifecycle
  transitions. A future rotation event creates a new event in the
  TL, not a row mutation.
- toDomain rehydrates AnchorClaim from the columns (PublicKeyJWK
  left nil).

Tests:
- Round-trip parametric across FQDN/DID/LEI claims and the
  no-claim legacy path: each saves, reads back, confirms type +
  resolved_id match, confirms PublicKeyJWK is absent.
- Distinct fixtures per case avoid the per-FQDN base-only
  uniqueness check.

Coverage holds at 90.3%. All 168 existing tests continue to pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan G Slice 6: opens the V2 register handler to anchor-aware
registrations and surfaces the anchor type on V2 list and detail
responses so clients can distinguish FQDN, DID, and LEI agents.

Wire shape:
  POST /v2/ans/agents
  {
    "agentDisplayName": "...",
    "agentHost": "agent.example.com",
    "endpoints": [...],
    "anchor": {
      "anchorType": "did",
      "input": "did:web:agent.example.com"
    }
  }

The anchor block is OPTIONAL. When omitted, the registration
takes the legacy FQDN-implicit path (existing behavior). When
present, the handler:
  - Validates anchorType is one of fqdn, did, lei.
  - Validates input is non-empty.
  - For the fqdn profile, validates input matches agentHost
    (case-insensitive).
  - Constructs an IdentityClaim with anchorType + input as the
    canonical resolved ID and passes it through to the service.

The handler-side IdentityClaim has PublicKeyJWK left empty: full
resolver dispatch (fetching the DID document, verifying the
GLEIF chain) lands in a later slice. The current shape stores
the anchor type so V2 list and detail responses surface it,
which is enough for live testing and for downstream tooling to
filter by anchor profile.

DTO additions:
- listItem.anchorType + anchorResolvedId
- agentDetails.anchorType + anchorResolvedId
Both omitempty so legacy registrations emit nothing.

A shared anchorFields helper reads (anchorType, anchorResolvedId)
from a registration's AnchorClaim, returning ("", "") for nil
claims. Centralizing the read keeps the list and detail emission
paths in lockstep.

Tests:
- resolveAnchorClaim happy paths for fqdn, did, lei.
- INVALID_ANCHOR_TYPE for missing or unknown types.
- MISSING_ANCHOR_INPUT when input is empty.
- ANCHOR_INPUT_AGENT_HOST_MISMATCH when fqdn input diverges
  from agentHost.
- Case-insensitive fqdn matching against agentHost.
- anchorFields helper for nil/empty claim and populated claim.

Coverage holds at 90.3%. The (nil, nil) return for the omitted-
anchor signal is documented and nolint:nilnil tagged because it
is the documented "no anchor block" sentinel; an alternate would
be a custom typed wrapper but the existing nil-claim convention
in the service layer is simpler.

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