Skip to content

[AI assisted] Plan F: base-only registration (no version, no Identity CSR)#14

Open
scourtney-godaddy wants to merge 4 commits into
feat/plan-d-consolidated-svcbfrom
feat/plan-f-base-only
Open

[AI assisted] Plan F: base-only registration (no version, no Identity CSR)#14
scourtney-godaddy wants to merge 4 commits into
feat/plan-d-consolidated-svcbfrom
feat/plan-f-base-only

Conversation

@scourtney-godaddy
Copy link
Copy Markdown

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

Lets an agent register without a version and 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 version and no identityCsrPEM. The
resulting registration has no ANSName and the RA issues no
Identity Certificate. The agent is identified by its FQDN alone,
carried in the agentHost field on the AgentRegistration
aggregate. This PR implements the base-only path end-to-end.

Domain layer:

  • AgentRegistration gains an AgentHost field (always populated
    post-validation). For versioned registrations it derives from
    AnsName.FQDN(); for base-only it carries the operator-supplied
    FQDN directly.
  • NewRegistration enforces the both-or-neither invariant on
    version + identityCsr. A versioned registration without a
    CSR returns VERSIONED_REQUIRES_IDENTITY_CSR. A base-only
    registration with a CSR returns BASE_ONLY_REJECTS_IDENTITY_CSR.
  • DNS records emitted for base-only registrations omit the
    version= field per ANS_SPEC.md §4.4.1.

Service + handler layer:

  • The V2 register handler validates the both-or-neither rule before
    building the RegisterRequest and routes the FQDN identity to
    the service through the explicit AgentHost field.
  • Identity-CSR signing in VerifyACME is gated on
    reg.IsBaseOnly(). Base-only registrations skip the cert
    issuance branch and the cert persistence branch; the lifecycle
    state machine still advances PENDING_VALIDATION →
    PENDING_DNS → ACTIVE through the same calls.

Storage layer:

  • Migration 008 makes ans_name nullable. Two empty-string
    base-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.
  • ExistsByAnsName short-circuits to false for the zero-value
    AnsName.
  • New ExistsActiveBaseOnlyByAgentHost enforces FQDN uniqueness
    for base-only registrations: only one live row per FQDN.
  • The V2 list and detail handlers read AgentHost and gate
    version emission on !reg.IsBaseOnly() so base-only rows
    emit version: "" rather than "0.0.0".

AnsName.String() returns empty string for the zero value rather
than 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 agentHost populated and version, ansName empty.

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

Test plan

  • make check (gofmt + golangci-lint + 90% coverage gate)
  • Domain invariant tests (versioned/CSR both-or-neither)
  • Storage round-trip test (anchor_type, agent_host, NULL ans_name)
  • V2 list/detail tests (base-only emission shape)
  • Lifecycle test (TestVerifyACME_BaseOnly_NoIdentityCSRRequired)
  • Lifecycle regression test (TestVerifyACME_Versioned_StillSignsIdentityCSR)
  • Reviewer confirms migration 008 applies cleanly (SQLite rebuild ceremony)

🤖 Generated with Claude Code

scourtney-godaddy and others added 4 commits May 16, 2026 11:47
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>
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