[AI assisted] Plan F: base-only registration (no version, no Identity CSR)#14
Open
scourtney-godaddy wants to merge 4 commits into
Open
[AI assisted] Plan F: base-only registration (no version, no Identity CSR)#14scourtney-godaddy wants to merge 4 commits into
scourtney-godaddy wants to merge 4 commits into
Conversation
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
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