Skip to content

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

Open
scourtney-godaddy wants to merge 12 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 12 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 12 commits May 15, 2026 20:49
…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>
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