Skip to content

feat(ra): AI Catalog generation, FQDN exclusivity, and seal-before-success activation - #47

Merged
csnitker-godaddy merged 4 commits into
mainfrom
claude/competent-johnson-ffac28
Jul 18, 2026
Merged

feat(ra): AI Catalog generation, FQDN exclusivity, and seal-before-success activation#47
csnitker-godaddy merged 4 commits into
mainfrom
claude/competent-johnson-ffac28

Conversation

@csnitker-godaddy

@csnitker-godaddy csnitker-godaddy commented Jun 16, 2026

Copy link
Copy Markdown
Member

Summary

Adds producer-side AI Catalog artifact generation to the RA, plus the two invariants it depends on. Every byte is derived from the registration aggregate — nothing crawled, nothing fetched — and the registration request and 202 response are untouched.

What's in this PR

1. AI Catalog generation (IMPL slices 1–2)

  • internal/catalog (pure, no I/O): the per-agent CatalogEntry and the host-complete ai-catalog.json document. Eligibility gates (versioned, ACTIVE, an A2A/MCP endpoint with a policy-passing metaDataUrl), single vs nested entries, leftmost-DNS-label URN, Cc/Cf text sanitization, and an emitted-URL policy (absolute https, host == agentHost, no userinfo/query/fragment).
  • Routes (owner-scoped via ReadOwnership):
    • GET /v2/ans/agents/{agentId}/catalog-entry — the bare entry (application/json).
    • GET /v2/ans/agents/{agentId}/ai-catalog — the host-complete document (application/ai-catalog+json) with a strong ETag + If-None-Match/304. This is the literal ai-catalog.json an Agent-Host Provider republishes at https://{agentHost}/.well-known/ai-catalog.json.
  • spec/api-spec-v2.yaml and the embedded docsui copy updated.

2. FQDN exclusivity (one-host-one-owner)

Once a registration is live (ACTIVE/DEPRECATED) on an FQDN, a different owner may not register or activate on it — checked at register, verify-acme, and verify-dns. On activation the winner cancels losing pending registrations (no TL event, ANS-1 §4.4).

3. Seal-before-success activation (ANS-1 §12.3)

verify-dns seals the single terminal AGENT_REGISTERED event inline and reports ACTIVE only after the TL acknowledges. A failing or unconfigured sealer fails closed: 503 TL_UNAVAILABLE, the agent stays PENDING_DNS, nothing is committed, no outbox row. Revocation still rides the outbox. This is what guarantees a catalogued agent's SCITT-receipt and badge links point at TL records that actually exist.

The ai-catalog.json (the deliverable)

scripts/demo/ai-catalog.sh builds one host with a mixed population and saves the validated document to data/demo/ai-catalog.json:

agent status in document
v1.0.0 MCP + metaDataUrl ACTIVE, eligible
v2.0.0 A2A + metaDataUrl ACTIVE, eligible
v3.0.0 HTTP-API (no card) ACTIVE, ineligible
v4.0.0 MCP PENDING
eligible agent on a different host ACTIVE

It asserts host-completeness, the host object, sorted-by-version, per-card mediaType, the agent-scoped alias (byte-identical via any agentId), a deterministic ETag, and 304.

Quality gates

  • make check green at 90.2% coverage (internal/catalog at 100%); go test -race clean on the affected packages.
  • Demos pass end-to-end: run-lifecycle.sh (V2), run-lifecycle-v1.sh (V1), catalog.sh (per-agent entry), ai-catalog.sh (host document).
  • Pre-push adversarial review across five dimensions (shape/contract, correctness, security, quality-bar, tests) with each finding independently verified; confirmed findings addressed in this PR (mostly comment/test accuracy), refuted findings documented.

Known limitations / follow-ups

  • FQDN exclusivity is best-effort under concurrency. Two different owners each holding a PENDING_DNS registration on the same FQDN can race verify-dns and both reach ACTIVE (the check runs before the inline seal and outside the activation tx). An in-tx re-check would trade this for a sealed-but-not-activated orphan under seal-before-success, so the proper fix is a pre-seal host claim (mirroring the identity lane's nonce claim) — deferred. Documented in preflightRegistrationConflicts. The owner-scoped catalog read contains the blast radius.
  • Public discovery route not built. IMPL §6 models the catalog routes as public/unauthenticated; per an earlier maintainer call these are owner-scoped behind auth. The genuinely public host-discovery route GET /v2/ans/catalog?agentHost= (IMPL slice 3) is unbuilt — that's the surface an AHP fetches without owner credentials.
  • Catalog updatedAt uses registration/last-renewal time as a documented proxy for the spec's "activation/latest seal timestamp" (the RA persists no activation timestamp; the field is optional).

🤖 Generated with Claude Code

@liady

liady commented Jun 22, 2026

Copy link
Copy Markdown

One small note - ARD spec v0.9 specifies urn:air: as the identifier prefix instead ofurn:ai:.

csnitker-godaddy added a commit that referenced this pull request Jun 24, 2026
…iew)

The catalog entry/document identifier NID is `urn:air:` (ARD spec v0.9
§4.2 Catalog Entry Object), not `urn:ai:`. Corrects the prefix across
the generator, unit tests, the OpenAPI contract and its embedded docsui
copy, and the demo scripts. The `:agents:` namespace segment is
unchanged. Addresses review feedback from a catalog author on PR #47.

Signed-off-by: Connor Snitker <csnitker@godaddy.com>
@csnitker-godaddy
csnitker-godaddy force-pushed the claude/competent-johnson-ffac28 branch from fee67fc to c945d6e Compare June 24, 2026 14:53
csnitker-godaddy added a commit that referenced this pull request Jul 16, 2026
…iew)

The catalog entry/document identifier NID is `urn:air:` (ARD spec v0.9
§4.2 Catalog Entry Object), not `urn:ai:`. Corrects the prefix across
the generator, unit tests, the OpenAPI contract and its embedded docsui
copy, and the demo scripts. The `:agents:` namespace segment is
unchanged. Addresses review feedback from a catalog author on PR #47.

Signed-off-by: Connor Snitker <csnitker@godaddy.com>
@csnitker-godaddy
csnitker-godaddy force-pushed the claude/competent-johnson-ffac28 branch from c945d6e to 3a7550d Compare July 16, 2026 15:25
@csnitker-godaddy
csnitker-godaddy marked this pull request as ready for review July 16, 2026 15:26
Copilot AI review requested due to automatic review settings July 16, 2026 15:26

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds producer-side AI Catalog artifact generation to the RA (per-agent entry + host-complete document), and introduces two supporting invariants: FQDN exclusivity across owners and seal-before-success activation (inline TL seal before reporting ACTIVE).

Changes:

  • Add internal/catalog (pure generation) plus authenticated RA routes to serve a CatalogEntry and host-complete ai-catalog.json with ETag/304.
  • Enforce one-host-one-owner for live (ACTIVE/DEPRECATED) registrations and cancel conflicting pending registrations on winner activation.
  • Change activation (verify-dns) to seal AGENT_REGISTERED inline (fail closed with TL_UNAVAILABLE) and update demos/tests/spec accordingly.

Reviewed changes

Copilot reviewed 32 out of 32 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
spec/api-spec-v2.yaml OpenAPI updates for AI Catalog routes, new schemas, and new 409/503 semantics.
internal/adapter/docsui/openapi/ra.yaml DocsUI OpenAPI mirror of the v2 spec changes.
internal/catalog/catalog.go New core types/constants for AI Catalog entry/document shapes.
internal/catalog/generate.go Build CatalogEntry from registration aggregate with eligibility gates and sanitization.
internal/catalog/document.go Build host-complete document and deterministic sorting for stable ETags.
internal/catalog/sanitize.go Strip Cc/Cf runes from emitted text and sanitize tags.
internal/catalog/urlpolicy.go Enforce emitted metaDataUrl policy (https, same host, no userinfo/query/fragment).
internal/catalog/generate_test.go Tests for entry generation, nesting, gating, sanitization, and TL base URL behavior.
internal/catalog/document_test.go Tests for host document inclusion rules and deterministic ordering.
internal/catalog/sanitize_test.go Tests for Cc/Cf stripping and tag de-duplication.
internal/catalog/urlpolicy_test.go Tests for emitted URL policy enforcement.
internal/ra/handler/catalog.go New authenticated handlers for /catalog-entry and /ai-catalog including ETag/304.
internal/ra/handler/catalog_test.go HTTP-level tests for catalog routes, ownership scoping, and conditional GET behavior.
internal/ra/handler/sealer_test.go Test sealer implementation to capture inline-sealed agent events at the handler layer.
internal/ra/handler/lifecycle_test.go Update lifecycle tests for inline sealing; add FQDN exclusivity and 503-on-seal-failure tests.
internal/ra/handler/v1lifecycle_test.go Update V1 lifecycle tests to expect inline sealing (outbox empty for activation).
internal/ra/service/registration.go Add conflict preflight (ANS name + host), host exclusivity helpers, and inline activation sealing plumbing.
internal/ra/service/lifecycle.go Implement inline TL seal-before-success on verify-dns; cancel conflicting pending registrations in-tx.
internal/ra/service/sealer_test.go Test sealer implementation to capture inline-sealed agent events at the service layer.
internal/ra/service/registration_test.go Fixture updates to wire the new agent sealer into service tests.
internal/ra/service/order_flow_test.go Update tests to read sealed activation events from the sealer instead of the outbox.
internal/ra/service/lifecycle_test.go Update activation tests for inline sealing; add fail-closed coverage for seal failures / nil sealer.
internal/ra/service/hostcatalog_test.go White-box tests for owner-scoped HostRegistrations and host exclusivity helpers.
internal/domain/agent.go Add CancelForHostConflict to cancel pending registrations without emitting a TL event.
internal/domain/agent_test.go Tests for CancelForHostConflict semantics (no event, pending-only).
internal/adapter/tlclient/client.go Add SealAgentEvent client method with transient/permanent error mapping.
cmd/ans-ra/main.go Wire TL sealer and TL public base URL into services; register catalog routes.
scripts/demo/common.sh Add shared AI catalog demo helpers and TL public base env var.
scripts/demo/run-lifecycle.sh Demo updates for inline sealing confirmation and catalog route validation.
scripts/demo/run-lifecycle-v1.sh Demo updates for inline sealing confirmation on the V1 lane.
scripts/demo/catalog.sh New demo script for per-agent catalog-entry scenarios (eligible/ineligible).
scripts/demo/ai-catalog.sh New demo script for host-complete document generation + ETag/304 assertions.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread internal/ra/service/registration.go
Comment thread internal/ra/handler/lifecycle_test.go Outdated
…s activation

Adds producer-side AI Catalog artifact generation to the RA, plus the two
invariants it leans on. Everything is derived from the registration
aggregate — nothing crawled, nothing fetched, and the registration request
and 202 response are untouched.

AI Catalog (IMPL slices 1-2)
- internal/catalog: pure generation of the per-agent CatalogEntry and the
  host-complete ai-catalog.json document. Eligibility gates (versioned,
  ACTIVE, A2A/MCP endpoint with a policy-passing metaDataUrl), single vs
  nested entries, leftmost-DNS-label URN, Cc/Cf text sanitization, and an
  emitted-URL policy (absolute https, host==agentHost, no
  userinfo/query/fragment).
- GET /v2/ans/agents/{agentId}/catalog-entry (bare entry) and
  GET /v2/ans/agents/{agentId}/ai-catalog (host-complete document with a
  strong ETag + If-None-Match/304). Owner-scoped via ReadOwnership.
- spec/api-spec-v2.yaml and the embedded docsui copy updated.

FQDN exclusivity (one-host-one-owner)
- Once a registration is live (ACTIVE/DEPRECATED) on an FQDN, a different
  owner may not register or activate on it; checked at register,
  verify-acme, and verify-dns. On activation the winner cancels losing
  pending registrations (no TL event, ANS-1 §4.4). The check is
  best-effort against a concurrent pending-window race (documented; a
  pre-seal host claim is the follow-up).

Seal-before-success activation (ANS-1 §12.3)
- verify-dns seals the single terminal AGENT_REGISTERED event INLINE and
  reports ACTIVE only after the TL acknowledges. A failing or unconfigured
  sealer fails closed: TL_UNAVAILABLE (503), the agent stays PENDING_DNS,
  nothing is committed, and no outbox row is written. Revocation still
  rides the outbox. This is what lets a catalogued agent's SCITT-receipt
  and badge links point at TL records that actually exist.

Tests and demos
- Unit coverage for catalog generation (100%), fail-closed activation
  (sealer error + nil sealer), and FQDN exclusivity. make check green at
  90.2%; race detector clean.
- scripts/demo/ai-catalog.sh produces and validates the host-complete
  ai-catalog.json (multi-agent host, ineligible/pending/other-host
  excluded, agent-scoped alias, deterministic ETag, 304). catalog.sh
  covers the per-agent entry scenarios. Shared demo helpers factored into
  common.sh.

Signed-off-by: Connor Snitker <csnitker@godaddy.com>
…iew)

The catalog entry/document identifier NID is `urn:air:` (ARD spec v0.9
§4.2 Catalog Entry Object), not `urn:ai:`. Corrects the prefix across
the generator, unit tests, the OpenAPI contract and its embedded docsui
copy, and the demo scripts. The `:agents:` namespace segment is
unchanged. Addresses review feedback from a catalog author on PR #47.

Signed-off-by: Connor Snitker <csnitker@godaddy.com>
…discovery service

Reconciles this branch with main's ARD discovery work (#46) at the two
points where the features genuinely interact, and addresses the PR #47
Copilot review.

Feed visibility for inline-sealed activations
- The agent-events feed (GET /v1/agents/events, the Finder's ingest
  source) is a read model over DELIVERED outbox rows, and the inline
  activation seal bypasses the outbox worker — so a sealed
  AGENT_REGISTERED would never surface on the feed and the Finder would
  never discover the agent. SealAgentEvent now returns the TL ack's
  logId, and VerifyDNS records a PRE-DELIVERED outbox row
  (OutboxStore.RecordSealed: payload + sent_at_ms + log_id at insert)
  inside the activation transaction: feed visibility commits atomically
  with ACTIVE, the worker never claims the row, and the feed serves the
  exact bytes the TL verified. Pinned by
  TestVerifyDNS_SealedActivationIsFeedVisible and end-to-end by the
  lifecycle demo's Finder discovery step.

Finder-parity URN labels
- The Finder mints urn:air:{host}:agents:{label} with the label derived
  from the labelized display name (internal/finder/project/urn.go); the
  catalog derived it from the leftmost DNS label, so search results and
  the published ai-catalog.json handed consumers two different lineage
  identifiers for the same agent. The catalog now applies the Finder's
  derivation (trim, collapse whitespace runs to hyphens, preserve case,
  lowercase host) so both surfaces mint ONE handle — also fixing the
  collision where two distinct same-version agents on one host would
  have shared a leftmost-label URN. A registration whose display name
  is missing or sanitizes away to nothing is not catalog-eligible
  (NO_LABEL), mirroring the Finder's skip. This supersedes the earlier
  leftmost-label choice; the lifecycle demo now asserts the Finder and
  the catalog return the identical URN.

Copilot review fixes
- The 409 AGENT_HOST_TAKEN detail now says a live (ACTIVE or
  DEPRECATED) registration holds the FQDN — DEPRECATED blocks reuse
  too, and the old text implied otherwise.
- A handler-test comment claimed the exclusivity check is re-checked
  atomically at activation; aligned with the documented best-effort
  semantics (the check runs before the inline seal, outside the tx).

Signed-off-by: Connor Snitker <csnitker@godaddy.com>
@csnitker-godaddy
csnitker-godaddy force-pushed the claude/competent-johnson-ffac28 branch from 3a7550d to 2643c2d Compare July 17, 2026 12:09

@kperry-godaddy kperry-godaddy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Connor — this is a strong PR. The seal-before-success mechanics are genuinely well built (fail-closed nil-sealer handling, the seal running outside the write lock, byte-exact payload persisted onto the pre-delivered feed row), the host document is correctly owner-scoped at both the handler and service layers, the byte-pinned golden test against the spec is exactly how we keep wire shapes honest, and the test-enforced docsui sync eliminated a whole class of review work. The comments in the tricky spots are candid about their trade-offs, which made this a pleasure to review.

I've left seven inline comments on things I'd like us to resolve before merge: two correctness edges on the new exclusivity invariant (the race window has consequences beyond the documented one, including a certificate-lifecycle gap), two observability gaps on the new activation lane, and three wire-contract items. Every one of them looks like a contained, mechanical fix — no redesign anywhere. Happy to pair on any of them.

Comment thread internal/ra/service/registration.go
Comment thread internal/ra/service/registration.go
Comment thread internal/ra/handler/catalog.go Outdated
Comment thread internal/ra/service/registration.go
Comment thread internal/catalog/generate.go Outdated
Comment thread internal/catalog/urlpolicy.go
Comment thread spec/api-spec-v2.yaml Outdated
…catalog per review

Addresses the PR #47 review (kperry-godaddy).

Activation race is now decided in-transaction (was best-effort)
- commitActivation replaces the blind post-seal Save. Under the store's
  single writer it is the authoritative exclusivity decision: it
  re-reads the row and the host's rows inside the activation tx and
  (a) aborts with AGENT_HOST_TAKEN if a rival committed ACTIVE/DEPRECATED
  during the seal round trip — no more two-ACTIVE wedge where each owner
  409s the other with no way out; and (b) requires its own row still be
  PENDING_DNS before Save, so a rival that conflict-cancelled it mid-seal
  is not silently resurrected from the stale in-memory ACTIVE aggregate.
  The loser's already-sealed leaf is the accepted benign residue. New
  TestVerifyDNS_Rival* pin both interleavings via a seal-round-trip hook.

Conflict-cancelled losers now lose their identity certs
- A loser cancelled at PENDING_DNS held a VALID identity cert (signed at
  verify-acme). commitActivation now mirrors cancelPending: CA-side
  revocation before the tx (idempotent), cert-row flips inside it. And
  Revoke's idempotent already-REVOKED branch now sweeps any lingering
  VALID cert (sweepRevokedAgentCerts), so the state self-heals if one
  slips through the cancellation window rather than being unreachable.

Catalog hardening
- CatalogHandler embeds responder and routes 5xx through h.writeError, so
  a store fault on either catalog route is logged (the RA has no
  request-log middleware); the 422 stays a bare 4xx.
- RegistrationService gains WithLogger: INFO on activation seal success
  (agentId, fqdn, schemaVersion, logId, duration) before commit, so an
  orphaned leaf is traceable; WARN/ERROR with .Err on seal failure; INFO
  per conflict-cancelled loser. tlclient logs the raw transport cause at
  the adapter boundary before the domain mapping strips it.
- Nested child entries reuse the parent displayName verbatim — the
  " (A2A)"/" (MCP)" suffix could push a max-length (64) name past the
  published CatalogEntry cap and self-invalidate the document.
- validateEmittedURL rejects Cc/Cf runes in the raw URL before parsing,
  restoring the finder's defense-in-depth (url.Parse passes bidi/
  zero-width runes in the path).
- The URN derivation (labelize + mint) is extracted to internal/ard,
  imported by both the finder projection and the catalog, so the
  one-identifier-per-agent invariant is structural, not two mirrored
  copies. Pinned by internal/ard tests + a catalog seam test.

Spec
- Add a Problem schema (RFC 7807) and point THIS PR's new error
  responses at it as application/problem+json — the shape handlers
  actually emit (both catalog routes' 4xx/5xx, the register/verify-acme
  409s, and the verify-dns 409/503). Pre-existing ErrorResponse
  declarations are left for a follow-up.

Signed-off-by: Connor Snitker <csnitker@godaddy.com>

@kperry-godaddy kperry-godaddy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving — this is excellent follow-through, Connor. Every item landed as a real fix rather than a patch-over:

  • commitActivation makes host exclusivity authoritative at commit time, and the new race tests pin both interleavings (including the no-wedge retry) via the seal-round-trip hook — really nice test design.
  • The loser certificate lifecycle now mirrors cancelPending end to end, and the self-healing sweep turns the previously-unreachable state into a repair path.
  • internal/ard is the right structural answer to the duplication: the one-identifier invariant is now enforced by the import graph instead of by comments.
  • The seal/cancellation logging (with the transport cause preserved at the client boundary) and the Problem schema both landed exactly where they needed to.

Verified on my side: make check green at 90.7% coverage, go test -race clean across every touched package, and the docsui copy still byte-identical to spec/.

Two tiny non-blocking notes so they're recorded for a follow-up: (1) a rival whose verify-acme commits inside the window between the pre-tx CA pass and the activation transaction gets its cert rows correctly flipped in-store but misses the CA-side revocation — and the sweep can't see that case, since it keys on store-VALID rows. Having commitActivation return the certs it flipped and CA-revoking exactly that set post-commit (idempotent) would close it completely. (2) A concurrent duplicate verify-dns for the same agent now surfaces the second commit as AGENT_HOST_TAKEN, which reads as "another operator" for your own now-ACTIVE agent — cosmetic, and a retry self-corrects via the idempotent branch. Neither needs to hold this up. And yes to your offer on pulling sanitizeText into internal/ard — that finishes the trio.

Great work. 🚢

@csnitker-godaddy
csnitker-godaddy added this pull request to the merge queue Jul 18, 2026
Merged via the queue into main with commit daf5ba1 Jul 18, 2026
3 checks passed
@csnitker-godaddy
csnitker-godaddy deleted the claude/competent-johnson-ffac28 branch July 18, 2026 20:07
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants