Skip to content

Design: Partial Product Archetype Implementation

Date: 2026-04-03 Status: Approved

Goal

Refactor the domain model to properly follow the Product Archetype pattern (ref: Software-Archetypes/archetypes). The current CatalogEntry conflates "what an agent IS" (ProductType) with "how it is cataloged" (CatalogEntry). This refactor separates those concerns.

Key Decisions

Decision Choice Rationale
Archetype scope Partial — AgentType + CatalogEntry, no ProductInstance/Batch Service discovery doesn't need physical instance tracking
AgentType : CatalogEntry 1:1 In service discovery, endpoint is identity — one agent = one catalog entry
Database Two tables (agent_types + catalog_entries) Clean separation, no legacy data to migrate
Parser returns *AgentType (not *CatalogEntry) Parser knows what agent IS, handler knows how to catalog it
source in Parse Removed Source is a catalog concept (how discovered), not a product concept (what it is)
Capabilities storage Single capabilities table with kind discriminator + properties JSON Extensible — new protocol = new kind values, zero migrations
Provider storage Dedicated providers table, reusable across agents Providers repeat (same org has many agents), deduplication makes sense
RawCard renamed raw_definition ([]byte, NOT NULL) Protocol-agnostic name; A2A has "card", MCP has "manifest"
Versioning agent_key = SHA256(protocol + endpoint) as partition key Same agent can have multiple versions, history is traceable
Skills field Removed from model Skills were A2A-centric. All capabilities (A2A skills, MCP tools, resources, prompts) go through polymorphic Capability interface

Current State (Before)

CatalogEntry (one struct, one table — everything mixed)
  ├── DisplayName, Description      ← catalog concern
  ├── Categories, Validity, Metadata ← catalog concern
  ├── Status, Source                 ← catalog concern
  ├── Protocol, Endpoint, Version   ← product type concern
  ├── Skills[]                      ← A2A-centric capabilities
  ├── TypedMeta[]                   ← A2A-only extra metadata
  ├── Provider                      ← product type concern
  ├── RawCard                       ← A2A-centric name
  └── SpecVersion                   ← product type concern

Target State (After)

Domain Model

Provider                             — reusable organization entity
  ├── ID, Organization, Team, URL

AgentType (= ProductType)            — "what the agent IS"
  ├── ID (UUID)
  ├── AgentKey (SHA256 of protocol+endpoint) — partition key for versioning
  ├── Protocol
  ├── Endpoint
  ├── Version
  ├── SpecVersion
  ├── Provider (FK → providers)
  ├── Capabilities (FK ← capabilities) — polymorphic ProductFeatureTypes
  ├── RawDefinition ([]byte, NOT NULL)
  └── CreatedOn

CatalogEntry (= commercial wrapper)  — "how the agent is cataloged"
  ├── ID (UUID)
  ├── AgentType (FK → agent_types)
  ├── DisplayName
  ├── Description
  ├── Categories
  ├── Validity (from/to/last_seen)
  ├── Metadata (key-value)
  ├── Status
  ├── Source
  ├── CreatedAt, UpdatedAt

Capability (= ProductFeatureType)    — polymorphic, discriminated by kind
  ├── ID (UUID)
  ├── AgentType (FK → agent_types)
  ├── Kind ("a2a.skill", "mcp.tool", ...)
  ├── Name
  ├── Description
  └── Properties (JSON — protocol-specific fields)

Database Schema

CREATE TABLE providers (
    id TEXT PRIMARY KEY,               -- UUID
    organization TEXT NOT NULL,
    team TEXT,
    url TEXT,
    created_on DATETIME NOT NULL,
    UNIQUE(organization, team)
);

CREATE TABLE agent_types (
    id TEXT PRIMARY KEY,               -- UUID
    agent_key TEXT NOT NULL,            -- SHA256(protocol + endpoint)
    protocol TEXT NOT NULL,
    endpoint TEXT NOT NULL,
    version TEXT NOT NULL,
    spec_version TEXT DEFAULT '',
    provider_id TEXT REFERENCES providers(id),
    raw_definition BLOB NOT NULL,
    created_on DATETIME NOT NULL,
    UNIQUE(agent_key, version)
);

CREATE TABLE capabilities (
    id TEXT PRIMARY KEY,               -- UUID
    agent_type_id TEXT NOT NULL REFERENCES agent_types(id) ON DELETE CASCADE,
    kind TEXT NOT NULL,                -- "a2a.skill", "mcp.tool", etc.
    name TEXT NOT NULL,
    description TEXT DEFAULT '',
    properties TEXT DEFAULT '{}',      -- JSON for protocol-specific fields
    UNIQUE(agent_type_id, kind, name)
);

CREATE TABLE catalog_entries (
    id TEXT PRIMARY KEY,               -- UUID
    agent_type_id TEXT NOT NULL REFERENCES agent_types(id),
    display_name TEXT NOT NULL,
    description TEXT DEFAULT '',
    categories TEXT DEFAULT '[]',      -- JSON array
    metadata TEXT DEFAULT '{}',        -- JSON key-value
    status TEXT NOT NULL DEFAULT 'unknown',
    source TEXT NOT NULL,
    validity_from DATETIME,
    validity_to DATETIME,
    validity_last_seen DATETIME NOT NULL,
    created_at DATETIME,
    updated_at DATETIME
);

ER Diagram

erDiagram
    providers ||--o{ agent_types : "owns"
    agent_types ||--o{ capabilities : "has"
    agent_types ||--|| catalog_entries : "cataloged as"

    providers {
        UUID id PK
        TEXT organization
        TEXT team
        TEXT url
        DATETIME created_on
    }
    agent_types {
        UUID id PK
        TEXT agent_key "SHA256(protocol+endpoint)"
        TEXT protocol
        TEXT endpoint
        TEXT version
        TEXT spec_version
        UUID provider_id FK
        BLOB raw_definition
        DATETIME created_on
    }
    capabilities {
        UUID id PK
        UUID agent_type_id FK
        TEXT kind
        TEXT name
        TEXT description
        TEXT properties "JSON"
    }
    catalog_entries {
        UUID id PK
        UUID agent_type_id FK
        TEXT display_name
        TEXT description
        TEXT categories "JSON array"
        TEXT metadata "JSON key-value"
        TEXT status
        TEXT source
        DATETIME validity_from
        DATETIME validity_to
        DATETIME validity_last_seen
        DATETIME created_at
        DATETIME updated_at
    }

Capability Kinds

Kind Protocol Common Fields Properties (JSON)
a2a.skill A2A name, description tags, input_modes, output_modes
a2a.interface A2A name (= url), description url, binding
a2a.security_scheme A2A name (= type), description type, method
a2a.extension A2A name (= uri), description uri, required
a2a.signature A2A name (= algorithm), description algorithm, key_id
mcp.tool MCP name, description input_schema
mcp.resource MCP name, description uri
mcp.prompt MCP name, description arguments

Go Interface Changes

Capability interface (replaces TypedMetadata)

// Capability represents a ProductFeatureType — a polymorphic capability of an agent.
type Capability interface {
    Kind() string
    Validate() error
}

AgentType struct

type AgentType struct {
    ID            string
    AgentKey      string          // SHA256(protocol + endpoint)
    Protocol      Protocol
    Endpoint      string
    Version       string
    SpecVersion   string
    ProviderID    string
    Provider      *Provider
    Capabilities  []Capability
    RawDefinition []byte
    CreatedOn     time.Time
}

ParserPlugin interface

// Before:
Parse(raw []byte, source SourceType) (*CatalogEntry, error)
Validate(raw []byte) ValidationResult

// After:
Parse(raw []byte) (*AgentType, error)
Validate(raw []byte) ValidationResult

Parser returns AgentType with Provider and Capabilities populated. Handler wraps it in CatalogEntry adding source, status, validity, display_name, categories.

Handler wiring (import/register)

// 1. Parser produces AgentType
agentType, err := parser.Parse(raw)

// 2. Upsert provider (find or create by org+team)
provider := agentType.Provider
providerID := store.UpsertProvider(ctx, provider)

// 3. Create AgentType with capabilities
agentType.ProviderID = providerID
agentType.AgentKey = sha256(agentType.Protocol + agentType.Endpoint)
store.CreateAgentType(ctx, agentType) // cascades to capabilities

// 4. Wrap in CatalogEntry
entry := &CatalogEntry{
    AgentTypeID: agentType.ID,
    DisplayName: agentType.Capabilities.FirstSkillName() or agentType.Endpoint,
    Source:      model.SourcePush,
    Status:      model.StatusUnknown,
    Validity:    model.Validity{LastSeen: time.Now()},
}
store.CreateCatalogEntry(ctx, entry)

API Backward Compatibility

REST endpoints return the same flat JSON structure as today. CatalogEntry.MarshalJSON() flattens AgentType + CatalogEntry into a single object. Frontend requires no changes.

Response shape (unchanged):

{
  "id": "catalog-entry-uuid",
  "display_name": "My Agent",
  "protocol": "a2a",
  "endpoint": "https://...",
  "version": "1.0.0",
  "status": "unknown",
  "source": "push",
  "capabilities": [...],
  "provider": { "organization": "...", "team": "..." },
  "validity": { "from": null, "to": null, "last_seen": "..." },
  "categories": [],
  "metadata": {},
  "raw_definition": "base64...",
  "created_at": "..."
}

Migration Strategy

No production deployments exist. Replace existing migration scripts with new schema. Drop old catalog_entries table, create 4 new tables.

Files Changed

File Change
internal/model/agent.go Replace CatalogEntry monolith with AgentType, CatalogEntry, Provider structs
internal/model/capability.go New — Capability interface + registry (replaces typed_metadata.go)
internal/model/a2a_capabilities.go New — A2ASkill, A2AInterface, A2ASecurityScheme, A2AExtension, A2ASignature
internal/model/mcp_capabilities.go New — MCPTool, MCPResource, MCPPrompt
internal/model/typed_metadata.go Remove (replaced by capability.go)
internal/model/a2a_metadata.go Remove (replaced by a2a_capabilities.go)
internal/model/skill.go Remove (replaced by capabilities)
internal/db/migrations.go Rewrite — 4 new tables
internal/store/sql_store.go Rewrite — AgentType + CatalogEntry CRUD with joins
internal/store/provider_store.go New — Provider upsert/CRUD
internal/store/capability_store.go New — Capability CRUD
internal/kernel/plugin.go Update ParserPlugin.Parse signature
plugins/parsers/a2a/a2a.go Return *AgentType, populate A2A capabilities
plugins/parsers/a2a/validation.go Update if needed
plugins/parsers/mcp/mcp.go Return *AgentType, populate MCP capabilities (tools, resources, prompts)
internal/api/handlers.go Wrap AgentType in CatalogEntry
internal/api/import_handler.go Update for new Parse signature
internal/api/register_handler.go Update for new Parse signature
internal/api/validate_handler.go May need minor updates
internal/service/card_fetcher.go No changes expected
web/src/types.ts Update TypeScript types (capabilities instead of skills + typed_meta)
internal/model/archetype_test.go New — reflective architecture test enforcing the Product Archetype pattern
Tests All test files updated for new model

Archetype Compliance Test

arch-go enforces layer boundaries (which package imports which) but cannot enforce the Product Archetype domain pattern (which types reference which, what fields a struct has). To prevent archetype violations at CI time, a reflective Go test verifies structural invariants:

File: internal/model/archetype_test.go

Rules enforced

Rule What it checks
AgentType has no catalog fields AgentType struct must NOT have fields: DisplayName, Description, Categories, Status, Source, Metadata, Validity
CatalogEntry has no product fields CatalogEntry struct must NOT have fields: Protocol, Endpoint, RawDefinition, Capabilities, Skills, TypedMeta, SpecVersion
CatalogEntry references AgentType CatalogEntry struct MUST have field AgentTypeID of type string
Capability interface is satisfied All registered capability types (A2ASkill, A2AInterface, A2ASecurityScheme, A2AExtension, A2ASignature, MCPTool, MCPResource, MCPPrompt) must implement Capability interface
ParserPlugin.Parse returns AgentType ParserPlugin interface method Parse must return (*AgentType, error), not (*CatalogEntry, error)
AgentType has required fields AgentType struct MUST have: ID, AgentKey, Protocol, Endpoint, RawDefinition, CreatedOn

Implementation approach

Uses reflect.TypeOf to inspect struct fields and interface methods at test time. No runtime overhead, runs in make test.

func TestArchetype_AgentTypeHasNoCatalogFields(t *testing.T) {
    catalogOnlyFields := []string{"DisplayName", "Description", "Categories",
        "Status", "Source", "Metadata", "Validity"}
    agentTypeT := reflect.TypeOf(AgentType{})
    for _, name := range catalogOnlyFields {
        _, found := agentTypeT.FieldByName(name)
        assert.False(t, found, "AgentType must not have catalog field %q", name)
    }
}

func TestArchetype_CatalogEntryHasNoProductFields(t *testing.T) {
    productOnlyFields := []string{"Protocol", "Endpoint", "RawDefinition",
        "Capabilities", "Skills", "TypedMeta", "SpecVersion"}
    entryT := reflect.TypeOf(CatalogEntry{})
    for _, name := range productOnlyFields {
        _, found := entryT.FieldByName(name)
        assert.False(t, found, "CatalogEntry must not have product field %q", name)
    }
}

func TestArchetype_CatalogEntryReferencesAgentType(t *testing.T) {
    entryT := reflect.TypeOf(CatalogEntry{})
    field, found := entryT.FieldByName("AgentTypeID")
    assert.True(t, found, "CatalogEntry must have AgentTypeID field")
    assert.Equal(t, "string", field.Type.Kind().String())
}

Out of Scope

  • ProductInstance / Batch / SerialNumber (not applicable to service discovery)
  • ProductCatalog aggregate with domain commands (CRUD store is sufficient for now)
  • Frontend UI redesign (JSON shape stays backward compatible)