feat(ra): AI Catalog generation, FQDN exclusivity, and seal-before-success activation - #47
Conversation
|
One small note - ARD spec v0.9 specifies |
…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>
fee67fc to
c945d6e
Compare
…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>
c945d6e to
3a7550d
Compare
There was a problem hiding this comment.
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 aCatalogEntryand host-completeai-catalog.jsonwith 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 sealAGENT_REGISTEREDinline (fail closed withTL_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.
…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>
3a7550d to
2643c2d
Compare
kperry-godaddy
left a comment
There was a problem hiding this comment.
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.
…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
left a comment
There was a problem hiding this comment.
Approving — this is excellent follow-through, Connor. Every item landed as a real fix rather than a patch-over:
commitActivationmakes 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
cancelPendingend to end, and the self-healing sweep turns the previously-unreachable state into a repair path. internal/ardis 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
Problemschema 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. 🚢
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-agentCatalogEntryand the host-completeai-catalog.jsondocument. Eligibility gates (versioned, ACTIVE, an A2A/MCP endpoint with a policy-passingmetaDataUrl), single vs nested entries, leftmost-DNS-label URN,Cc/Cftext sanitization, and an emitted-URL policy (absolutehttps,host == agentHost, no userinfo/query/fragment).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 literalai-catalog.jsonan Agent-Host Provider republishes athttps://{agentHost}/.well-known/ai-catalog.json.spec/api-spec-v2.yamland 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-dnsseals the single terminalAGENT_REGISTEREDevent inline and reports ACTIVE only after the TL acknowledges. A failing or unconfigured sealer fails closed:503 TL_UNAVAILABLE, the agent staysPENDING_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.shbuilds one host with a mixed population and saves the validated document todata/demo/ai-catalog.json: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 checkgreen at 90.2% coverage (internal/catalogat 100%);go test -raceclean on the affected packages.run-lifecycle.sh(V2),run-lifecycle-v1.sh(V1),catalog.sh(per-agent entry),ai-catalog.sh(host document).Known limitations / follow-ups
PENDING_DNSregistration on the same FQDN can raceverify-dnsand 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 inpreflightRegistrationConflicts. The owner-scoped catalog read contains the blast radius.GET /v2/ans/catalog?agentHost=(IMPL slice 3) is unbuilt — that's the surface an AHP fetches without owner credentials.updatedAtuses 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