[AI assisted] Plan F: base-only registration (no version, no Identity CSR)#14
Open
scourtney-godaddy wants to merge 12 commits into
Open
[AI assisted] Plan F: base-only registration (no version, no Identity CSR)#14scourtney-godaddy wants to merge 12 commits into
scourtney-godaddy wants to merge 12 commits into
Conversation
…attestations Optional Registration Metadata path per ANS_SPEC.md §A.1: operators submit the ANS Trust Card body, the RA computes SHA-256(JCS(content)) at activation, and seals the hex-lowercase digest into the V2 AGENT_REGISTERED TL event under attestations.metadataHashes.capabilitiesHash. The AIM later verifies the live hosted Trust Card against the sealed hash. Reuses the existing metadataHashes map rather than introducing a new struct field, since the map already accommodates well-known hash keys. Spec-only change. Implementation lands in subsequent commits. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…s key
Reserve the Attestations.MetadataHashes["capabilitiesHash"] key for the
SHA-256(JCS(agentCardContent)) hash sealed at activation per
ANS_SPEC.md §A.1. Export MetadataHashKeyCapabilitiesHash so the RA
service and AIM verifier reach for the same constant rather than
string-literalling the key.
Tests pin three invariants the AIM relies on:
1. The map omits the key when no agentCardContent was submitted.
2. nil and empty MetadataHashes produce identical canonical bytes
(leaf-hash stability across the absence boundary).
3. The hex digest is lowercase 64-char.
No envelope shape change. The map already existed; this commit adds
a constant, documents the convention, and reads from the existing
shape.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ivation
Implements the §A.1 "hash and forget" Registration Metadata flow:
1. RegisterRequest carries optional AgentCardContent ([]byte) on the
V2 path. The service JCS-canonicalizes (RFC 8785), SHA-256
hashes, and stores the hex-lowercase digest as
AgentRegistration.CapabilitiesHash. The raw content is then
discarded — only the digest persists.
2. Migration 006 adds the capabilities_hash column on
agent_registrations (nullable for backwards compatibility and
for the spec-conformant "no content submitted" path).
3. Activation reads reg.CapabilitiesHash and, when populated,
writes it into the AGENT_REGISTERED event under
attestations.metadataHashes.capabilitiesHash. Empty stays absent.
The metadataHashes map is the right home for this digest: it already
exists, already has omitempty semantics, and the well-known key
constant lives next to its consumers in internal/tl/event.
Validation: malformed JSON (JCS canonicalization fails) returns
INVALID_AGENT_CARD_CONTENT rather than silently dropping the digest.
Tests pin: hash stored, hash absent when content omitted,
JCS-equivalent bodies hash identically across registrations,
malformed JSON rejected.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds the optional agentCardContent field to the V2 registrationRequest
DTO and forwards the raw bytes through to service.RegisterRequest.
Modeled as json.RawMessage so the operator-submitted bytes reach the
JCS canonicalizer without an intermediate map[string]any round-trip
that could shift the digest.
Tests cover:
1. Field plumbed end-to-end (POST 202 → aggregate carries hash).
2. Field omitted (CapabilitiesHash empty, no metadataHashes
entry at activation).
3. Malformed body returns 422 BAD_JSON without reaching the service.
The fixture exposes the agents store + context.Background() so handler
tests can assert on the persisted aggregate without standing up a
parallel verify-acme/verify-dns flow.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…GISTERED Drives the full §A.1 flow against the in-memory fixture: register-with-agentCardContent → verify-acme → verify-dns → claim the AGENT_REGISTERED outbox row → assert innerEventCanonical.attestations.metadataHashes.capabilitiesHash equals SHA-256(JCS(agentCardContent)) computed independently by the test. Also extracts the hash-and-store logic into applyAgentCardContentHash so RegisterAgent stays under the funlen 130-line ceiling (the new helper + the existing hashAgentCardContent live in helpers.go). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Extends domain.ComputeRequiredDNSRecords to emit one SVCB record per protocol at the agent bare FQDN, alongside the existing _ans TXT family. The SVCB row carries: alpn=PROTOCOL from endpoint.Protocol port=443 ServiceMode SvcPriority 1 at the FQDN wk=SUFFIX A2A: agent-card.json; MCP: mcp.json card-sha256=BASE64URL base64url of reg.CapabilitiesHash when set card-sha256 and capabilities_hash are the section 4.4.2 cross-check encodings of the same SHA-256 (DNS uses base64url, TL uses hex). When the operator did not submit agentCardContent, the SvcParam is absent and verifiers fall back to TOFU on first Trust Card fetch. Adds verifySVCB to LookupVerifier mirroring verifyHTTPS. Tests cover present-matching, absent (zone has different name), and wrong-target cases (AliasMode where ServiceMode was expected). Provisional SvcParams (wk, card-sha256) are unit-tested at the domain layer because miekg/dns rejects them in zone-file form until IANA registration; the verifier- level test exercises only registered SvcParamKeys (alpn, port). Required=false: section 4.4.2 marks Consolidated Approach SVCB as MAY, opt-in during the _ans TXT transition. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds dnsRecordStyle to the V2 RegistrationRequest with three values: "consolidated" (default, recommended), "legacy" (original _ans TXT shape), "both" (transition union). Empty -> consolidated. Invalid -> 422 INVALID_DNS_RECORD_STYLE. The default points new integrations at the lean Consolidated Approach shape per section 4.4.2 SHOULD: one SVCB record at the bare FQDN per protocol, plus shared _ans-prefixed records and TLSA. Operators on existing zone-edit tooling for _ans TXT pick "legacy" explicitly. Migration operators set "both" for a defined window then flip back to "consolidated". V1 lane pins to "legacy" regardless of the request because V1 callers predate the Consolidated Approach and their tooling expects the original shape. V1 has no dnsRecordStyle field on the wire. Migration 007 adds the dns_record_style column on agent_registrations. Nullable for backwards compatibility with pre-Plan-D rows. Tests: - "both" emits 2x _ans TXT + 2x SVCB + shared records (existing test updated to set DNSRecordStyleBoth so it exercises the union path). - New tests cover "consolidated" (no _ans TXT), "legacy" (no SVCB), and "both" (union); the SvcParam wk/card-sha256 tests already covered the consolidated path implicitly. - Lint: extracted applyDNSRecordStyle helper to keep RegisterAgent under the funlen ceiling. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Closes a long-standing spec/impl gap: ANS_SPEC.md section A.8.1 lists the HTTPS RR (RFC 9460 type 65) at the agent FQDN as RA-generated content the AHP provisions, but ComputeRequiredDNSRecords had never emitted it. The DNSRecordHTTPS enum value and verifyHTTPS verifier were already in place; this commit wires the emission. Generated only for the legacy + both styles, not for consolidated: the SVCB rows the consolidated form publishes already carry the same alpn/port/ECH SvcParams the HTTPS RR would, so emitting both would duplicate content and risk the two records drifting (section A.8.2 explicitly notes this). Operators on the consolidated path who still want HTTPS-RR-aware clients (typically browsers) to see the metadata can publish their own HTTPS RR as a side addition. Required=false: HTTPS RR is blocked by CNAME at the agent FQDN per RFC 1034 section 3.6.2. AHPs whose apex is fronted via CNAME cannot publish it at the same name; the RA does not block verify-dns on its absence. Tests pin: legacy style includes HTTPS RR + no SVCB; consolidated style includes SVCB + no HTTPS RR; both style includes both families. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Implements ANS_SPEC.md section 3.2.0 base-only registration path: the registrant submits NEITHER a version nor an Identity CSR, yielding a registration with no ANSName and no Identity Certificate, identified by FQDN alone. Mirrors PR #974 against the Kotlin RA. Domain: - AgentRegistration gains AgentHost field (always set; canonical FQDN). - AnsName field stays but may be zero-value for base-only registrations. - IsBaseOnly() helper for emission paths. - NewRegistration accepts agentHost parameter and enforces the both- or-neither invariant: version + identityCsr are coupled, mixed forms rejected with VERSIONED_REQUIRES_IDENTITY_CSR or BASE_ONLY_REJECTS_IDENTITY_CSR. Handler / service: - V2 register endpoint marks version + identityCsrPEM as optional. - resolveAnsNameForRegister() helper centralizes the both-or-neither validation at the API boundary. - buildOptionalIdentityCSR() materializes a CSR aggregate only when the operator submitted one. DNS record emission: - _ans TXT records omit the version= field for base-only. - _ans-badge TXT records omit version= for base-only. - SVCB rows still emit (no version SvcParam in either form). - Identity Cert TLSA (_ans-identity._tls) is AHP-managed and absent from RA output regardless; base-only further means no Identity Certificate is ever issued. Tests pin: missing CSR with version → VERSIONED_REQUIRES_IDENTITY_CSR. CSR with no version → BASE_ONLY_REJECTS_IDENTITY_CSR. The pre-Plan-F "missing ans name" test was renamed to capture the new semantics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Live testing the §3.2.0 base-only path against the demo RA exposed several spots where the implementation still assumed a versioned ANSName. This commit closes the POST /v2/ans/agents path so multiple base-only registrations can coexist on the same RA instance. Domain: - AnsName.String() returns "" for the zero value rather than the malformed "ans://v0.0.0." that surfaced in API responses. Service: - Resolve fqdn once at the top of RegisterAgent and reuse for all cert validators. Pre-Plan-F code read req.AnsName.FQDN() inline, which returned "" for base-only and tripped INVALID_SERVER_CSR. - ValidateIdentityCSR is gated on IdentityCSRPEM != "" — base-only requests submit no CSR, and the handler+resolveAnsNameForRegister has already enforced the both-or-neither invariant. - SaveCSR is gated on IdentityCSR != nil, eliminating a nil-pointer panic at uow time. - Uniqueness check forks: versioned uses ExistsByAnsName as before; base-only uses ExistsActiveBaseOnlyByAgentHost so two base-only registrations on the same FQDN cannot coexist while letting distinct FQDNs register independently. Storage: - Migration 008 relaxes ans_name to nullable. Pre-Plan-F it was TEXT NOT NULL UNIQUE; two base-only registrations stored "" and collided on UNIQUE. The new schema persists NULL for the zero AnsName, which UNIQUE allows in unbounded multiplicity per SQLite semantics. - agentRow.AnsName is sql.NullString; toDomain decodes NULL/empty as the zero AnsName and populates AgentHost from the row directly so loaded base-only aggregates round-trip with their FQDN intact. - Save persists agent.AnsName.String() through nullableString and reads agent.FQDN() for the agent_host column instead of agent.AnsName.FQDN(), which was empty for base-only. Port: - AgentStore gains ExistsActiveBaseOnlyByAgentHost; the existing middleware test fake adds a no-op implementation. Live verification: registered four project skills against the local demo RA in sequence (ans-registration, ans-deep-analysis, ans-watchtower-analyze, ans-meeting-brief). Each returned 202 with distinct agentIds and persisted with NULL ans_name + populated agent_host. The verify-acme flow still assumes an Identity CSR is pending; gating that path for base-only is deferred to a follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The V2 list and detail handlers were still reading reg.AnsName.FQDN() and reg.AnsName.Version() inline, which surfaced empty strings (or the nonsense "0.0.0" version) for §3.2.0 base-only registrations whose AnsName is the zero value. The list was the surface that finally exposed it: agentHost came back empty even though the row stored a populated agent_host column. Handler: - mapListResponse and mapAgentDetails read AgentHost from reg.FQDN() (which falls back to AgentHost when AnsName is zero) and gate Version emission on !reg.IsBaseOnly() so base-only items emit Version="" rather than "0.0.0". Service: - Same change applied across the service layer call sites that pass the FQDN downstream to cert validators / signers — renewal.go and lifecycle.go switched from reg.AnsName.FQDN() to reg.FQDN(). These paths are versioned-only today, but the read pattern is now consistent so a base-only registration that reaches them will not silently lose its identity. - checkRegistrationUniqueness extracted from RegisterAgent to keep funlen under threshold and remove the nested-if depth lint hit the inline branching introduced. Storage: - ExistsActiveBaseOnlyByAgentHost matches both NULL and empty-string ans_name values so the predicate stays correct across an in-place upgrade where some pre-008 rows could still be empty strings rather than NULL. Tests: - Unit tests pin the new uniqueness check: PENDING_VALIDATION base-only counts as claimed, REVOKED releases the FQDN, versioned rows do not collide, and ExistsByAnsName(zero) short-circuits to false. Coverage holds at 90%. Live confirmation against the demo RA: 4 base-only project skills registered → V2 list returns wrapper shape with returnedCount=4, agentHost populated, version empty, status PENDING_VALIDATION across both pages of a cursor-driven walk. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Plan F follow-up (#63): pre-fix, verify-acme on a base-only
registration returned MISSING_IDENTITY_CSR — base-only registers
no Identity CSR by definition, so the lifecycle could never reach
ACTIVE. Plan G's non-FQDN anchors (DID, LEI) all rely on the
base-only path because NON_FQDN_REQUIRES_BASE_ONLY forced them
there at registration time, so without this fix every DID/LEI
registration sat in PENDING_VALIDATION forever.
Fix: gate the identity-cert issuance + persistence branches on
reg.IsBaseOnly():
- Versioned registrations: unchanged. Fetch pending CSR, sign
through the IdentityCertificateAuthority port, persist the
signed CSR + the StoredCertificate row, advance to PENDING_DNS.
- Base-only registrations: skip the fetch + sign + persist. The
aggregate still advances to PENDING_DNS through the standard
state-machine call. No identity cert is created; the store
sees no row.
- Server CSR path: unchanged regardless of anchor type. BYOC and
CSR-signed server certs work for any anchor.
verify-dns needed no change: ComputeRequiredDNSRecords already
omits the identity-cert TLSA when no identity cert is present, and
buildAgentRegisteredEvent's identity-cert loop produces an empty
slice for base-only (no certs to enumerate). The transition to
ACTIVE proceeds cleanly.
Tests pin the new behavior:
- TestVerifyACME_BaseOnly_NoIdentityCSRRequired registers a
base-only agent (zero AnsName, empty IdentityCSRPEM, AgentHost
carries the FQDN identity), drives verify-acme, confirms the
aggregate advanced to PENDING_DNS and the cert table is empty.
- TestVerifyACME_Versioned_StillSignsIdentityCSR exercises the
unchanged versioned path: register with version + Identity CSR,
drive verify-acme, confirm 1 identity cert row was created.
Live verification: registered did:web:lifecycle-test.example.com
through the demo RA, drove POST verify-acme → PENDING_DNS,
POST verify-dns → ACTIVE. Pre-fix the verify-acme call returned
HTTP 409 MISSING_IDENTITY_CSR; post-fix it returns 202 with the
documented PENDING_DNS body and the agent reaches ACTIVE.
Coverage holds at 90.3%.
Closes Plan F follow-up (#63), unblocks the Plan G PR pipeline.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This was referenced May 16, 2026
4d2b2a0 to
e00928a
Compare
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Lets an agent register without a
versionand without an Identity CSR. The agent has no ANSName and no Identity Certificate; identity comes from the FQDN alone. Two callers want this: single-purpose agents that do not version their capabilities, and (after Plan G) anchors whose URI SAN is not FQDN-shaped.ANS_SPEC.md §3.2.0 admits a base-only registration path: the
operator submits no
versionand noidentityCsrPEM. Theresulting registration has no ANSName and the RA issues no
Identity Certificate. The agent is identified by its FQDN alone,
carried in the
agentHostfield on theAgentRegistrationaggregate. This PR implements the base-only path end-to-end.
Domain layer:
AgentRegistrationgains anAgentHostfield (always populatedpost-validation). For versioned registrations it derives from
AnsName.FQDN(); for base-only it carries the operator-suppliedFQDN directly.
NewRegistrationenforces the both-or-neither invariant onversion+identityCsr. A versioned registration without aCSR returns
VERSIONED_REQUIRES_IDENTITY_CSR. A base-onlyregistration with a CSR returns
BASE_ONLY_REJECTS_IDENTITY_CSR.version=field per ANS_SPEC.md §4.4.1.Service + handler layer:
building the
RegisterRequestand routes the FQDN identity tothe service through the explicit
AgentHostfield.VerifyACMEis gated onreg.IsBaseOnly(). Base-only registrations skip the certissuance branch and the cert persistence branch; the lifecycle
state machine still advances PENDING_VALIDATION →
PENDING_DNS → ACTIVE through the same calls.
Storage layer:
ans_namenullable. Two empty-stringbase-only rows previously collided on the UNIQUE constraint
with code 2067. SQLite treats each NULL as distinct under
UNIQUE, so multiple base-only registrations coexist.
ExistsByAnsNameshort-circuits to false for the zero-valueAnsName.
ExistsActiveBaseOnlyByAgentHostenforces FQDN uniquenessfor base-only registrations: only one live row per FQDN.
AgentHostand gateversionemission on!reg.IsBaseOnly()so base-only rowsemit
version: ""rather than"0.0.0".AnsName.String()returns empty string for the zero value ratherthan the malformed
"ans://v0.0.0."the previous shape produced.End-to-end behavior verified against the local demo RA: register
a base-only agent, drive verify-acme → PENDING_DNS, drive
verify-dns → ACTIVE, list and detail responses surface the
agent with
agentHostpopulated andversion,ansNameempty.Stacks on #12 (Plans A + C) → #13 (Plan D). Merge order:
#12 → #13 → this.
Test plan
make check(gofmt + golangci-lint + 90% coverage gate)TestVerifyACME_BaseOnly_NoIdentityCSRRequired)TestVerifyACME_Versioned_StillSignsIdentityCSR)🤖 Generated with Claude Code