From fe0a55011d9120faf5c93809b68c7e81785c43de Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 14:57:20 -0400 Subject: [PATCH 1/3] feat(domain,service): plumb optional AnchorClaim through registration 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) --- internal/domain/agent.go | 47 ++++++++++-- internal/domain/agent_test.go | 106 ++++++++++++++++++++++++++-- internal/ra/service/registration.go | 14 +++- 3 files changed, 157 insertions(+), 10 deletions(-) diff --git a/internal/domain/agent.go b/internal/domain/agent.go index f09814c..e8aaf6e 100644 --- a/internal/domain/agent.go +++ b/internal/domain/agent.go @@ -135,6 +135,20 @@ type AgentRegistration struct { // is treated as DefaultDNSRecordStyle by ComputeRequiredDNSRecords. DNSRecordStyle DNSRecordStyle `json:"dnsRecordStyle,omitempty"` + // AnchorClaim records the verified IdentityClaim that produced + // this registration, when the caller supplied one through ANS-0's + // AnchorResolver port. Nil for legacy FQDN-only deployments where + // the registration's identity is implicit in AgentHost (and AnsName + // when versioned). When non-nil, AnchorClaim.AnchorType authoritatively + // identifies the anchor profile — non-FQDN profiles (DID, LEI) force + // base-only registration invariants because the X.509 URI SAN binding + // the versioned ANS-2 path relies on does not apply. + // + // Slice 5a stores AnchorClaim in-memory only; persistence lands in + // Slice 5b through migration 009 (anchor_type + anchor_resolved_id + // columns on agent_registrations). + AnchorClaim *IdentityClaim `json:"anchorClaim,omitempty"` + // PendingEvents holds domain events raised during this aggregate operation. // They are cleared after being published. PendingEvents []Event `json:"-"` @@ -157,6 +171,13 @@ type AgentRegistration struct { // Mixed forms are rejected: a version requires an Identity CSR (and // vice versa) — the Identity Certificate's URI SAN encodes the // ANSName, so the two artifacts are coupled. +// +// Non-FQDN anchors (DID, LEI) force the base-only path: today the +// versioned URI SAN binding is FQDN-shaped and a future amendment +// will admit DID-shaped URIs into the SAN. Until then, an +// AnchorClaim with anchorType in {did, lei} accompanied by a non-zero +// ansName or non-nil identityCSR is rejected with +// NON_FQDN_REQUIRES_BASE_ONLY. func NewRegistration( agentID string, ownerID string, @@ -167,6 +188,7 @@ func NewRegistration( endpoints []AgentEndpoint, serverCert *ByocServerCertificate, identityCSR *AgentCSR, + anchorClaim *IdentityClaim, now time.Time, ) (*AgentRegistration, error) { if agentID == "" { @@ -190,6 +212,20 @@ func NewRegistration( ) } + // Non-FQDN anchors force base-only until ANS-2 admits a DID-shaped URI + // SAN. A versioned (or CSR-bearing) DID/LEI registration is rejected + // at the boundary so the aggregate's invariants stay coherent. + if anchorClaim != nil && + anchorClaim.AnchorType != "" && + anchorClaim.AnchorType != AnchorTypeFQDN && + !baseOnly { + return nil, NewValidationError( + "NON_FQDN_REQUIRES_BASE_ONLY", + fmt.Sprintf("anchor type %q must be registered as base-only (no version, no identity CSR) until ANS-2 admits non-FQDN URI SANs", + anchorClaim.AnchorType), + ) + } + // Resolve canonical FQDN. Versioned: ansName carries it. Base-only: // caller passes agentHost explicitly. fqdn := strings.ToLower(strings.TrimSpace(agentHost)) @@ -242,11 +278,12 @@ func NewRegistration( } reg := &AgentRegistration{ - AgentID: agentID, - OwnerID: ownerID, - AnsName: ansName, - AgentHost: fqdn, - Status: StatusPendingValidation, + AgentID: agentID, + OwnerID: ownerID, + AnsName: ansName, + AgentHost: fqdn, + AnchorClaim: anchorClaim, + Status: StatusPendingValidation, Details: RegistrationDetails{ RegistrationTimestamp: now, DisplayName: displayName, diff --git a/internal/domain/agent_test.go b/internal/domain/agent_test.go index b8d9ab3..479b179 100644 --- a/internal/domain/agent_test.go +++ b/internal/domain/agent_test.go @@ -29,7 +29,7 @@ func newValidRegistration(t *testing.T) *AgentRegistration { reg, err := NewRegistration( "agent-uuid", "owner-1", ansName, "", "My Agent", "desc", - endpoints, cert, &csr, time.Now(), + endpoints, cert, &csr, nil, time.Now(), ) require.NoError(t, err) return reg @@ -75,7 +75,7 @@ func TestNewRegistration_Validations(t *testing.T) { } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - _, err := NewRegistration(tc.agentID, tc.ownerID, tc.ansName, "", tc.displayName, tc.description, tc.endpoints, tc.cert, tc.csr, time.Now()) + _, err := NewRegistration(tc.agentID, tc.ownerID, tc.ansName, "", tc.displayName, tc.description, tc.endpoints, tc.cert, tc.csr, nil, time.Now()) require.Error(t, err) var de *Error require.ErrorAs(t, err, &de) @@ -90,7 +90,7 @@ func TestNewRegistration_CertFQDNMismatch(t *testing.T) { ep := []AgentEndpoint{{Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}} cert := &ByocServerCertificate{SubjectCommonName: "other.example.com"} - _, err := NewRegistration("a", "o", ansName, "", "", "", ep, cert, &csr, time.Now()) + _, err := NewRegistration("a", "o", ansName, "", "", "", ep, cert, &csr, nil, time.Now()) assert.ErrorIs(t, err, ErrCertificate) } @@ -223,7 +223,7 @@ func TestNewRegistration_InvalidEndpoint(t *testing.T) { badEndpoints := []AgentEndpoint{ {Protocol: ProtocolMCP, AgentURL: "https://other.example.com/mcp"}, } - _, err := NewRegistration("a", "o", ansName, "", "", "", badEndpoints, nil, &csr, time.Now()) + _, err := NewRegistration("a", "o", ansName, "", "", "", badEndpoints, nil, &csr, nil, time.Now()) require.Error(t, err) assert.ErrorIs(t, err, ErrValidation) } @@ -312,3 +312,101 @@ func TestRegistrationDetails_EffectiveTimestamp(t *testing.T) { // Original unchanged. assert.Equal(t, reg, d.EffectiveTimestamp()) } + +func TestNewRegistration_AnchorClaim_FQDNStoredOnAggregate(t *testing.T) { + // FQDN claim alongside a versioned registration: claim attaches + // to the aggregate; existing versioned-FQDN behavior is preserved. + ansName, err := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + require.NoError(t, err) + csr := NewIdentityCSR("csr-1", "-----BEGIN CSR-----", time.Now()) + endpoints := []AgentEndpoint{ + {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + } + claim := &IdentityClaim{ + AnchorType: AnchorTypeFQDN, + ResolvedID: "agent.example.com", + PublicKeyJWK: []byte(`{"kty":"OKP"}`), + IssuedAt: time.Now().UTC(), + } + reg, err := NewRegistration( + "agent-uuid", "owner-1", ansName, "", "Agent", "", + endpoints, nil, &csr, claim, time.Now(), + ) + require.NoError(t, err) + require.NotNil(t, reg.AnchorClaim) + assert.Equal(t, AnchorTypeFQDN, reg.AnchorClaim.AnchorType) + assert.Equal(t, "agent.example.com", reg.AnchorClaim.ResolvedID) +} + +func TestNewRegistration_AnchorClaim_DIDForcedBaseOnly(t *testing.T) { + // A DID anchor claim accompanied by a versioned ansName is + // rejected: until ANS-2 admits non-FQDN URI SANs, DID + // registrations must take the base-only path. + ansName, err := NewAnsName(mustSemVer(1, 0, 0), "agent.example.com") + require.NoError(t, err) + csr := NewIdentityCSR("csr-1", "-----BEGIN CSR-----", time.Now()) + endpoints := []AgentEndpoint{ + {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + } + claim := &IdentityClaim{ + AnchorType: AnchorTypeDID, + ResolvedID: "did:web:agent.example.com", + PublicKeyJWK: []byte(`{"kty":"OKP"}`), + IssuedAt: time.Now().UTC(), + } + _, err = NewRegistration( + "agent-uuid", "owner-1", ansName, "agent.example.com", "Agent", "", + endpoints, nil, &csr, claim, time.Now(), + ) + require.Error(t, err) + assert.True(t, strings.Contains(err.Error(), "NON_FQDN_REQUIRES_BASE_ONLY") || + strings.Contains(err.Error(), "must be registered as base-only"), + "expected NON_FQDN_REQUIRES_BASE_ONLY, got %v", err) +} + +func TestNewRegistration_AnchorClaim_DIDBaseOnlyAccepted(t *testing.T) { + // DID + base-only is the only path admitted today: zero AnsName, + // nil CSR, AgentHost supplied (the service FQDN where the agent + // is reachable, distinct from the DID identity). + endpoints := []AgentEndpoint{ + {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + } + claim := &IdentityClaim{ + AnchorType: AnchorTypeDID, + ResolvedID: "did:web:agent.example.com", + PublicKeyJWK: []byte(`{"kty":"OKP"}`), + IssuedAt: time.Now().UTC(), + } + reg, err := NewRegistration( + "agent-uuid", "owner-1", AnsName{}, "agent.example.com", "Agent", "", + endpoints, nil, nil, claim, time.Now(), + ) + require.NoError(t, err) + require.True(t, reg.IsBaseOnly()) + require.NotNil(t, reg.AnchorClaim) + assert.Equal(t, AnchorTypeDID, reg.AnchorClaim.AnchorType) + assert.Equal(t, "did:web:agent.example.com", reg.AnchorClaim.ResolvedID) + // AgentHost (service FQDN) and AnchorClaim.ResolvedID are intentionally + // distinct for non-FQDN anchors. + assert.Equal(t, "agent.example.com", reg.AgentHost) +} + +func TestNewRegistration_AnchorClaim_LEIBaseOnlyAccepted(t *testing.T) { + endpoints := []AgentEndpoint{ + {Protocol: ProtocolMCP, AgentURL: "https://agent.example.com/mcp"}, + } + claim := &IdentityClaim{ + AnchorType: AnchorTypeLEI, + ResolvedID: "529900T8BM49AURSDO55", + PublicKeyJWK: []byte(`{"kty":"OKP"}`), + IssuedAt: time.Now().UTC(), + } + reg, err := NewRegistration( + "agent-uuid", "owner-1", AnsName{}, "agent.example.com", "Agent", "", + endpoints, nil, nil, claim, time.Now(), + ) + require.NoError(t, err) + require.NotNil(t, reg.AnchorClaim) + assert.Equal(t, AnchorTypeLEI, reg.AnchorClaim.AnchorType) + assert.Equal(t, "529900T8BM49AURSDO55", reg.AnchorClaim.ResolvedID) +} diff --git a/internal/ra/service/registration.go b/internal/ra/service/registration.go index d47e7e8..2777082 100644 --- a/internal/ra/service/registration.go +++ b/internal/ra/service/registration.go @@ -102,6 +102,18 @@ type RegisterRequest struct { // surfaces as INVALID_DNS_RECORD_STYLE before the aggregate is // created. DNSRecordStyle domain.DNSRecordStyle + + // AnchorClaim is the verified IdentityClaim produced by an ANS-0 + // AnchorResolver when the caller registered through the anchor- + // aware path. Nil for legacy FQDN-only callers; the service + // passes it through to domain.NewRegistration verbatim. Non-FQDN + // claims force base-only invariants until ANS-2 admits non-FQDN + // URI SANs (NewRegistration enforces this; NON_FQDN_REQUIRES_BASE_ONLY + // fires when an anchored versioned registration is submitted). + // + // Slice 5a stores AnchorClaim only in-memory; persistence lands + // in Slice 5b through migration 009. + AnchorClaim *domain.IdentityClaim } // RegisterResponse is returned to the HTTP handler after a successful @@ -341,7 +353,7 @@ func (s *RegistrationService) RegisterAgent(ctx context.Context, req RegisterReq reg, err := domain.NewRegistration( agentID, req.OwnerID, req.AnsName, req.AgentHost, req.DisplayName, req.Description, - req.Endpoints, byocCert, csrPtr, now, + req.Endpoints, byocCert, csrPtr, req.AnchorClaim, now, ) if err != nil { return nil, err From 46abe96135085a87b58f215a6df2b6b32061bfc2 Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 15:00:14 -0400 Subject: [PATCH 2/3] feat(sqlite): persist AnchorClaim through migration 009 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) --- internal/adapter/store/sqlite/agent.go | 36 ++++++- internal/adapter/store/sqlite/agent_test.go | 93 +++++++++++++++++++ .../migrations/009_agent_anchor_claim.sql | 24 +++++ 3 files changed, 151 insertions(+), 2 deletions(-) create mode 100644 internal/adapter/store/sqlite/migrations/009_agent_anchor_claim.sql diff --git a/internal/adapter/store/sqlite/agent.go b/internal/adapter/store/sqlite/agent.go index 2d7c623..c81c85b 100644 --- a/internal/adapter/store/sqlite/agent.go +++ b/internal/adapter/store/sqlite/agent.go @@ -41,6 +41,8 @@ type agentRow struct { ACMEChallengeExpiresAtMs sql.NullInt64 `db:"acme_challenge_expires_at_ms"` CapabilitiesHash sql.NullString `db:"capabilities_hash"` DNSRecordStyle sql.NullString `db:"dns_record_style"` + AnchorType sql.NullString `db:"anchor_type"` + AnchorResolvedID sql.NullString `db:"anchor_resolved_id"` CreatedAtMs int64 `db:"created_at_ms"` UpdatedAtMs int64 `db:"updated_at_ms"` } @@ -88,9 +90,33 @@ func (r agentRow) toDomain() (*domain.AgentRegistration, error) { if r.DNSRecordStyle.Valid { reg.DNSRecordStyle = domain.DNSRecordStyle(r.DNSRecordStyle.String) } + // AnchorClaim is reconstructed from anchor_type + anchor_resolved_id. + // PublicKeyJWK and IssuedAt are intentionally not persisted: verifiers + // re-resolve the claim through the AnchorResolver on demand to honor + // the per-profile freshness budget. Pre-Plan-G rows have anchor_type + // NULL and surface a nil AnchorClaim downstream. + if r.AnchorType.Valid && r.AnchorType.String != "" { + reg.AnchorClaim = &domain.IdentityClaim{ + AnchorType: domain.AnchorType(r.AnchorType.String), + } + if r.AnchorResolvedID.Valid { + reg.AnchorClaim.ResolvedID = r.AnchorResolvedID.String + } + } return reg, nil } +// anchorClaimColumns returns the (anchor_type, anchor_resolved_id) +// values for the INSERT INTO agent_registrations row. Both are NULL +// when the aggregate carries no AnchorClaim (legacy FQDN-only path). +func anchorClaimColumns(claim *domain.IdentityClaim) (sql.NullString, sql.NullString) { + if claim == nil || claim.AnchorType == "" { + return sql.NullString{}, sql.NullString{} + } + return sql.NullString{String: string(claim.AnchorType), Valid: true}, + sql.NullString{String: claim.ResolvedID, Valid: true} +} + // Save inserts or updates an AgentRegistration. Endpoints, server cert, // and identity CSR are persisted via their dedicated tables — Save only // writes the root aggregate row. @@ -110,15 +136,19 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) acme_dns01_token, acme_challenge_expires_at_ms, capabilities_hash, dns_record_style, + anchor_type, anchor_resolved_id, created_at_ms, updated_at_ms - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)` // AnsName is the zero value for §3.2.0 base-only registrations. // Persist it as NULL so the UNIQUE constraint allows multiple // base-only rows (SQLite treats each NULL as distinct under // UNIQUE), and read the FQDN from agent.FQDN() which falls back // to agent.AgentHost when AnsName is zero. AnsName.String() // returns "" for the zero value; nullableString promotes it to - // SQL NULL. + // SQL NULL. AnchorClaim is the verified IdentityClaim from the + // caller; pre-Plan-G code paths leave it nil and the columns + // stay NULL. + anchorType, anchorResolvedID := anchorClaimColumns(agent.AnchorClaim) res, err := s.db.extx(ctx).ExecContext(ctx, q, agent.AgentID, agent.OwnerID, @@ -135,6 +165,8 @@ func (s *AgentStore) Save(ctx context.Context, agent *domain.AgentRegistration) nullableMs(agent.ACMEChallenge.ExpiresAt), nullableString(agent.CapabilitiesHash), nullableString(string(agent.DNSRecordStyle)), + anchorType, + anchorResolvedID, now, now, ) if err != nil { diff --git a/internal/adapter/store/sqlite/agent_test.go b/internal/adapter/store/sqlite/agent_test.go index 736b2b8..a77e28a 100644 --- a/internal/adapter/store/sqlite/agent_test.go +++ b/internal/adapter/store/sqlite/agent_test.go @@ -498,6 +498,99 @@ func TestAgentStore_ExistsActiveBaseOnlyByAgentHost(t *testing.T) { } } +func TestAgentStore_AnchorClaim_RoundTrip(t *testing.T) { + db := newTestDB(t) + store := NewAgentStore(db) + ctx := context.Background() + + cases := []struct { + name string + claim *domain.IdentityClaim + }{ + { + name: "FQDN claim", + claim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeFQDN, + ResolvedID: "agent.example.com", + }, + }, + { + name: "DID claim", + claim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeDID, + ResolvedID: "did:web:agent.example.com", + }, + }, + { + name: "LEI claim", + claim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeLEI, + ResolvedID: "529900T8BM49AURSDO55", + }, + }, + { + name: "no claim (legacy path)", + claim: nil, + }, + } + + for i, c := range cases { + t.Run(c.name, func(t *testing.T) { + // Distinct host + agent ID per case so the per-FQDN + // base-only uniqueness check does not interfere. + agentID := "agent-anchor-" + strconvI(i) + host := "agent" + strconvI(i) + ".example.com" + fixture := newBaseOnlyFixture(t, agentID, host) + fixture.AnchorClaim = c.claim + + if err := store.Save(ctx, fixture); err != nil { + t.Fatalf("Save: %v", err) + } + loaded, err := store.FindByID(ctx, fixture.ID) + if err != nil { + t.Fatalf("FindByID: %v", err) + } + if c.claim == nil { + if loaded.AnchorClaim != nil { + t.Errorf("expected nil AnchorClaim, got %+v", loaded.AnchorClaim) + } + return + } + if loaded.AnchorClaim == nil { + t.Fatal("expected AnchorClaim, got nil") + } + if loaded.AnchorClaim.AnchorType != c.claim.AnchorType { + t.Errorf("AnchorType: got %q, want %q", + loaded.AnchorClaim.AnchorType, c.claim.AnchorType) + } + if loaded.AnchorClaim.ResolvedID != c.claim.ResolvedID { + t.Errorf("ResolvedID: got %q, want %q", + loaded.AnchorClaim.ResolvedID, c.claim.ResolvedID) + } + // PublicKeyJWK is intentionally not persisted; verifiers + // re-resolve through the AnchorResolver to honor the + // per-profile freshness budget. + if len(loaded.AnchorClaim.PublicKeyJWK) != 0 { + t.Errorf("PublicKeyJWK should not be persisted, got %d bytes", + len(loaded.AnchorClaim.PublicKeyJWK)) + } + }) + } +} + +// strconvI keeps the test imports minimal. +func strconvI(n int) string { + if n == 0 { + return "0" + } + digits := []byte{} + for n > 0 { + digits = append([]byte{byte('0' + n%10)}, digits...) + n /= 10 + } + return string(digits) +} + // TestAgentStore_ExistsByAnsName_ZeroIsFalse pins the contract // guarding base-only registrations: looking up the zero-value AnsName // must short-circuit to false rather than match every empty ans_name diff --git a/internal/adapter/store/sqlite/migrations/009_agent_anchor_claim.sql b/internal/adapter/store/sqlite/migrations/009_agent_anchor_claim.sql new file mode 100644 index 0000000..7949bb9 --- /dev/null +++ b/internal/adapter/store/sqlite/migrations/009_agent_anchor_claim.sql @@ -0,0 +1,24 @@ +-- 009_agent_anchor_claim.sql +-- +-- Plan G Slice 5b: persist the AnchorClaim that produced a +-- registration. ANS-0 admits three anchor types (fqdn, did, lei); +-- the storage shape captures the type plus the canonical resolved +-- ID. The verification key (PublicKeyJWK) is intentionally NOT +-- stored on this row — verifiers re-resolve through the +-- AnchorResolver to honor the per-profile freshness budget, and +-- a stale stored JWK would defeat that. +-- +-- Both columns are nullable. Pre-Plan-G registrations have no +-- AnchorClaim recorded; their anchor_type stays NULL and the +-- aggregate exposes a nil AnchorClaim downstream. The application +-- layer infers an FQDN claim from agent_host for those legacy rows +-- when needed without writing into this column on read. +-- +-- Adding columns to an existing SQLite table is a simple ALTER; +-- no rebuild ceremony required. + +ALTER TABLE agent_registrations + ADD COLUMN anchor_type TEXT; + +ALTER TABLE agent_registrations + ADD COLUMN anchor_resolved_id TEXT; From 77783219f8a7743bd8e945e76e76de0d74ce8e5c Mon Sep 17 00:00:00 2001 From: scourtney-godaddy Date: Sat, 16 May 2026 15:05:02 -0400 Subject: [PATCH 3/3] feat(handler): V2 register accepts anchor block; list+detail surface it 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) --- internal/ra/handler/anchor_test.go | 148 ++++++++++++++++++++++++++++ internal/ra/handler/dto.go | 51 +++++++--- internal/ra/handler/registration.go | 97 ++++++++++++++++++ 3 files changed, 284 insertions(+), 12 deletions(-) create mode 100644 internal/ra/handler/anchor_test.go diff --git a/internal/ra/handler/anchor_test.go b/internal/ra/handler/anchor_test.go new file mode 100644 index 0000000..49250a7 --- /dev/null +++ b/internal/ra/handler/anchor_test.go @@ -0,0 +1,148 @@ +package handler + +import ( + "errors" + "testing" + + "github.com/godaddy/ans/internal/domain" +) + +// TestResolveAnchorClaim_OmittedReturnsNil pins the legacy path: +// when the V2 register request has no anchor block, the handler +// returns a nil claim and the service falls back to FQDN-implicit +// behavior. +func TestResolveAnchorClaim_OmittedReturnsNil(t *testing.T) { + claim, err := resolveAnchorClaim(nil, "agent.example.com") + if err != nil { + t.Fatalf("err: %v", err) + } + if claim != nil { + t.Errorf("expected nil claim, got %+v", claim) + } +} + +func TestResolveAnchorClaim_MissingType(t *testing.T) { + _, err := resolveAnchorClaim(&anchorRequestDTO{Input: "agent.example.com"}, "agent.example.com") + if err == nil { + t.Fatal("expected INVALID_ANCHOR_TYPE, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "INVALID_ANCHOR_TYPE" { + t.Errorf("expected INVALID_ANCHOR_TYPE, got %v", err) + } +} + +func TestResolveAnchorClaim_UnknownType(t *testing.T) { + _, err := resolveAnchorClaim(&anchorRequestDTO{ + AnchorType: "spiffe", + Input: "spiffe://example.org/foo", + }, "agent.example.com") + if err == nil { + t.Fatal("expected INVALID_ANCHOR_TYPE, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "INVALID_ANCHOR_TYPE" { + t.Errorf("expected INVALID_ANCHOR_TYPE, got %v", err) + } +} + +func TestResolveAnchorClaim_MissingInput(t *testing.T) { + _, err := resolveAnchorClaim(&anchorRequestDTO{ + AnchorType: "fqdn", + Input: "", + }, "agent.example.com") + if err == nil { + t.Fatal("expected MISSING_ANCHOR_INPUT, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "MISSING_ANCHOR_INPUT" { + t.Errorf("expected MISSING_ANCHOR_INPUT, got %v", err) + } +} + +func TestResolveAnchorClaim_FQDNAgentHostMustMatch(t *testing.T) { + _, err := resolveAnchorClaim(&anchorRequestDTO{ + AnchorType: "fqdn", + Input: "different.example.com", + }, "agent.example.com") + if err == nil { + t.Fatal("expected ANCHOR_INPUT_AGENT_HOST_MISMATCH, got nil") + } + var dErr *domain.Error + if !errors.As(err, &dErr) || dErr.Code != "ANCHOR_INPUT_AGENT_HOST_MISMATCH" { + t.Errorf("expected ANCHOR_INPUT_AGENT_HOST_MISMATCH, got %v", err) + } +} + +func TestResolveAnchorClaim_FQDNCaseInsensitive(t *testing.T) { + claim, err := resolveAnchorClaim(&anchorRequestDTO{ + AnchorType: "fqdn", + Input: "AGENT.example.com", + }, "agent.example.COM") + if err != nil { + t.Fatalf("expected case-insensitive match, got %v", err) + } + if claim.AnchorType != domain.AnchorTypeFQDN { + t.Errorf("AnchorType = %q", claim.AnchorType) + } +} + +func TestResolveAnchorClaim_DIDAcceptsArbitraryInput(t *testing.T) { + claim, err := resolveAnchorClaim(&anchorRequestDTO{ + AnchorType: "did", + Input: "did:web:agent.example.com", + }, "agent.example.com") + if err != nil { + t.Fatalf("err: %v", err) + } + if claim.AnchorType != domain.AnchorTypeDID { + t.Errorf("AnchorType = %q", claim.AnchorType) + } + if claim.ResolvedID != "did:web:agent.example.com" { + t.Errorf("ResolvedID = %q", claim.ResolvedID) + } +} + +func TestResolveAnchorClaim_LEIAcceptsTwentyChar(t *testing.T) { + claim, err := resolveAnchorClaim(&anchorRequestDTO{ + AnchorType: "lei", + Input: "529900T8BM49AURSDO55", + }, "agent.example.com") + if err != nil { + t.Fatalf("err: %v", err) + } + if claim.AnchorType != domain.AnchorTypeLEI { + t.Errorf("AnchorType = %q", claim.AnchorType) + } + if claim.ResolvedID != "529900T8BM49AURSDO55" { + t.Errorf("ResolvedID = %q", claim.ResolvedID) + } +} + +func TestAnchorFields_NilOrEmptyClaim(t *testing.T) { + at, ar := anchorFields(nil) + if at != "" || ar != "" { + t.Errorf("nil reg: anchor fields should be empty, got (%q, %q)", at, ar) + } + reg := &domain.AgentRegistration{} + at, ar = anchorFields(reg) + if at != "" || ar != "" { + t.Errorf("empty claim: anchor fields should be empty, got (%q, %q)", at, ar) + } +} + +func TestAnchorFields_PopulatedClaim(t *testing.T) { + reg := &domain.AgentRegistration{ + AnchorClaim: &domain.IdentityClaim{ + AnchorType: domain.AnchorTypeDID, + ResolvedID: "did:web:agent.example.com", + }, + } + at, ar := anchorFields(reg) + if at != "did" { + t.Errorf("AnchorType = %q, want did", at) + } + if ar != "did:web:agent.example.com" { + t.Errorf("AnchorResolvedID = %q", ar) + } +} diff --git a/internal/ra/handler/dto.go b/internal/ra/handler/dto.go index 80fa70b..e7ceebb 100644 --- a/internal/ra/handler/dto.go +++ b/internal/ra/handler/dto.go @@ -20,12 +20,18 @@ type listResponse struct { } type listItem struct { - AgentID string `json:"agentId"` - AgentDisplayName string `json:"agentDisplayName"` - AgentDescription string `json:"agentDescription,omitempty"` - Version string `json:"version"` - AgentHost string `json:"agentHost"` - AnsName string `json:"ansName"` + AgentID string `json:"agentId"` + AgentDisplayName string `json:"agentDisplayName"` + AgentDescription string `json:"agentDescription,omitempty"` + Version string `json:"version"` + AgentHost string `json:"agentHost"` + AnsName string `json:"ansName"` + // AnchorType + AnchorResolvedID surface the registration's + // ANS-0 anchor profile. Both omitted for legacy FQDN-implicit + // rows; populated for any registration that came in through + // the anchor block on V2 register. + AnchorType string `json:"anchorType,omitempty"` + AnchorResolvedID string `json:"anchorResolvedId,omitempty"` Status string `json:"status"` TTL int `json:"ttl"` RegistrationTimestamp string `json:"registrationTimestamp,omitempty"` @@ -33,6 +39,16 @@ type listItem struct { Links []linkDTO `json:"links"` } +// anchorFields returns the (anchorType, anchorResolvedId) pair for +// a registration's DTO emission. Both empty when the registration +// has no AnchorClaim attached (legacy rows). +func anchorFields(reg *domain.AgentRegistration) (string, string) { + if reg == nil || reg.AnchorClaim == nil { + return "", "" + } + return string(reg.AnchorClaim.AnchorType), reg.AnchorClaim.ResolvedID +} + func mapListResponse(res *service.ListResult) listResponse { items := make([]listItem, 0, len(res.Items)) for _, reg := range res.Items { @@ -49,6 +65,7 @@ func mapListResponse(res *service.ListResult) listResponse { if !reg.IsBaseOnly() { version = reg.AnsName.Version().String() } + anchorType, anchorResolved := anchorFields(reg) items = append(items, listItem{ AgentID: reg.AgentID, AgentDisplayName: reg.Details.DisplayName, @@ -56,6 +73,8 @@ func mapListResponse(res *service.ListResult) listResponse { Version: version, AgentHost: reg.FQDN(), AnsName: reg.AnsName.String(), + AnchorType: anchorType, + AnchorResolvedID: anchorResolved, Status: string(reg.Status), TTL: 300, RegistrationTimestamp: reg.Details.RegistrationTimestamp.Format("2006-01-02T15:04:05Z07:00"), @@ -82,12 +101,17 @@ func mapListResponse(res *service.ListResult) listResponse { // ----- Detail DTO (matches V2 spec AgentDetails §1086) ----- type agentDetails struct { - AgentID string `json:"agentId"` - AgentDisplayName string `json:"agentDisplayName"` - AgentDescription string `json:"agentDescription,omitempty"` - Version string `json:"version"` - AgentHost string `json:"agentHost"` - AnsName string `json:"ansName"` + AgentID string `json:"agentId"` + AgentDisplayName string `json:"agentDisplayName"` + AgentDescription string `json:"agentDescription,omitempty"` + Version string `json:"version"` + AgentHost string `json:"agentHost"` + AnsName string `json:"ansName"` + // AnchorType + AnchorResolvedID surface the registration's + // ANS-0 anchor profile. Both omitted for legacy FQDN-implicit + // registrations. + AnchorType string `json:"anchorType,omitempty"` + AnchorResolvedID string `json:"anchorResolvedId,omitempty"` AgentStatus string `json:"agentStatus"` Endpoints []endpointDTO `json:"endpoints"` RegistrationTimestamp string `json:"registrationTimestamp,omitempty"` @@ -112,6 +136,7 @@ func mapAgentDetails(res *service.DetailResult, r *http.Request) agentDetails { if !reg.IsBaseOnly() { version = reg.AnsName.Version().String() } + anchorType, anchorResolved := anchorFields(reg) d := agentDetails{ AgentID: reg.AgentID, AgentDisplayName: reg.Details.DisplayName, @@ -119,6 +144,8 @@ func mapAgentDetails(res *service.DetailResult, r *http.Request) agentDetails { Version: version, AgentHost: reg.FQDN(), AnsName: reg.AnsName.String(), + AnchorType: anchorType, + AnchorResolvedID: anchorResolved, AgentStatus: string(reg.Status), Endpoints: mapEndpointsToDTO(res.Endpoints), RegistrationTimestamp: reg.Details.RegistrationTimestamp.Format("2006-01-02T15:04:05Z07:00"), diff --git a/internal/ra/handler/registration.go b/internal/ra/handler/registration.go index df79891..ffe11f1 100644 --- a/internal/ra/handler/registration.go +++ b/internal/ra/handler/registration.go @@ -61,6 +61,27 @@ type registrationRequest struct { // value rejected with 422 INVALID_DNS_RECORD_STYLE. See // ANS_SPEC.md §4.4.2 for record-shape semantics. DNSRecordStyle string `json:"dnsRecordStyle,omitempty"` + + // Anchor optionally selects which ANS-0 anchor profile the + // caller wants applied. When omitted, the registration takes + // the legacy FQDN-implicit path (anchorType inferred from + // AgentHost). When present, the named profile resolver runs + // against AnchorInput and the resulting IdentityClaim attaches + // to the aggregate. Non-FQDN profiles (DID, LEI) force base- + // only registration invariants per the proposal at + // docs/proposals/2026-05-16-spec-skeletons/ans-0-identity-anchor.md. + Anchor *anchorRequestDTO `json:"anchor,omitempty"` +} + +// anchorRequestDTO is the V2 register request's anchor block. +// Slice 6 ships the wire shape; Slice 7+ wires the SDK and +// the actual resolver dispatch in the service layer. +type anchorRequestDTO struct { + // AnchorType is one of "fqdn", "did", "lei". Empty rejected. + AnchorType string `json:"anchorType"` + // Input is the anchor-resolver input string: an FQDN, a DID + // URI (did:web:...), or an ISO 17442 LEI. + Input string `json:"input"` } type endpointDTO struct { @@ -159,6 +180,12 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { return } + anchorClaim, err := resolveAnchorClaim(req.Anchor, req.AgentHost) + if err != nil { + WriteError(w, err) + return + } + resp, err := h.svc.RegisterAgent(r.Context(), service.RegisterRequest{ OwnerID: id.Subject, AnsName: ansName, @@ -166,6 +193,7 @@ func (h *RegistrationHandler) Register(w http.ResponseWriter, r *http.Request) { DisplayName: req.AgentDisplayName, Description: req.AgentDescription, Endpoints: eps, + AnchorClaim: anchorClaim, IdentityCSRPEM: req.IdentityCSRPEM, ServerCsrPEM: req.ServerCsrPEM, ServerCertificatePEM: req.ServerCertificatePEM, @@ -329,3 +357,72 @@ func resolveAnsNameForRegister(req *registrationRequest) (domain.AnsName, error) } return domain.NewAnsName(semver, req.AgentHost) } + +// resolveAnchorClaim translates the optional anchor block in the +// V2 register request into a verified domain.IdentityClaim that +// the service layer attaches to the aggregate. +// +// Slice 6 keeps the handler scope tight: it accepts the wire shape, +// validates the anchorType + input pair, and constructs an +// IdentityClaim with PublicKeyJWK left empty (a sentinel meaning +// "claim was not produced by an AnchorResolver"). Slice 7+ wires +// the SDK; subsequent slices will plumb a real +// port.AnchorResolver into the service layer so the handler-side +// shortcut goes away. For now, the handler-side claim is a typed +// breadcrumb the storage layer persists so V2 list/detail +// responses can surface the anchor type. +// +// Validation rules: +// - Anchor block omitted: returns nil claim; service falls back +// to legacy FQDN-implicit behavior. +// - Anchor block present with empty anchorType: returns +// INVALID_ANCHOR_TYPE. +// - Anchor block present with empty input: returns +// MISSING_ANCHOR_INPUT. +// - anchorType "fqdn" with input that does not match agentHost: +// returns ANCHOR_INPUT_AGENT_HOST_MISMATCH. The two fields +// must agree for the FQDN profile. +// - anchorType "did" or "lei": input is accepted as-is (lexical +// validation belongs to the resolver, which lands in a later +// slice; today the input is a typed breadcrumb). +func resolveAnchorClaim(anchor *anchorRequestDTO, agentHost string) (*domain.IdentityClaim, error) { + if anchor == nil { + return nil, nil //nolint:nilnil // (nil, nil) is the documented "no anchor block" signal + } + anchorType := strings.TrimSpace(strings.ToLower(anchor.AnchorType)) + input := strings.TrimSpace(anchor.Input) + switch anchorType { + case "": + return nil, domain.NewValidationError( + "INVALID_ANCHOR_TYPE", + "anchor.anchorType is required when anchor block is present", + ) + case string(domain.AnchorTypeFQDN), string(domain.AnchorTypeDID), string(domain.AnchorTypeLEI): + default: + return nil, domain.NewValidationError( + "INVALID_ANCHOR_TYPE", + "anchor.anchorType must be one of fqdn, did, lei (got "+anchorType+")", + ) + } + if input == "" { + return nil, domain.NewValidationError( + "MISSING_ANCHOR_INPUT", + "anchor.input is required when anchor block is present", + ) + } + // FQDN profile: input MUST match agentHost (case-insensitive). + // The two fields name the same operational identity, so a + // divergence is operator error caught at the boundary. + if anchorType == string(domain.AnchorTypeFQDN) { + if !strings.EqualFold(strings.TrimSpace(agentHost), input) { + return nil, domain.NewValidationError( + "ANCHOR_INPUT_AGENT_HOST_MISMATCH", + "anchor.input must match agentHost for the FQDN profile", + ) + } + } + return &domain.IdentityClaim{ + AnchorType: domain.AnchorType(anchorType), + ResolvedID: input, + }, nil +}