diff --git a/internal/agent/loop_types.go b/internal/agent/loop_types.go index acef5aeb..7a5f6af8 100644 --- a/internal/agent/loop_types.go +++ b/internal/agent/loop_types.go @@ -68,10 +68,17 @@ type CacheInvalidateFunc func(agentID uuid.UUID, userID string) // Loop is the agent execution loop for one agent instance. // Think → Act → Observe cycle with tool execution. type Loop struct { - id string - displayName string - agentUUID uuid.UUID // set for context propagation - tenantID uuid.UUID // agent's owning tenant + // id is the human-readable agent_key (e.g. "goctech-leader"). Use for logs, + // UI events, system prompt rendering, filesystem paths, and context keys. + // NEVER set on DB FK columns or DomainEvent.AgentID — those require UUID. + // See docs/agent-identity-conventions.md. + id string + displayName string + // agentUUID is the canonical DB primary key. Use for SQL WHERE/JOIN, + // DomainEvent.AgentID, OTel span attributes, and context propagation via + // store.WithAgentID. See docs/agent-identity-conventions.md. + agentUUID uuid.UUID + tenantID uuid.UUID // agent's owning tenant agentType string // "open" or "predefined" defaultTimezone string // system default timezone for bootstrap pre-fill provider providers.Provider diff --git a/internal/eventbus/event_types.go b/internal/eventbus/event_types.go index df35fdce..a9f76a70 100644 --- a/internal/eventbus/event_types.go +++ b/internal/eventbus/event_types.go @@ -27,15 +27,21 @@ const ( ) // DomainEvent is a typed event with metadata for the consolidation pipeline. +// +// Identity invariant: TenantID and AgentID are string fields for legacy wire +// compatibility, but consumers parse them as UUIDs before touching the DB. +// Publishers MUST supply valid UUID strings — never agent_key or tenant_slug. +// The publish-time observer in validate_agent_id.go warns on drift. +// See docs/agent-identity-conventions.md. type DomainEvent struct { - ID string // UUID v7 for ordering + ID string // UUID v7 for ordering Type EventType - SourceID string // dedup key (e.g. session key, run ID) - TenantID string - AgentID string + SourceID string // dedup key (e.g. session key, run ID) + TenantID string // MUST be a valid UUID string — never tenant_slug + AgentID string // MUST be a valid UUID string — never agent_key UserID string Timestamp time.Time - Payload any // typed per EventType (see payload structs below) + Payload any // typed per EventType (see payload structs below) } // --- Typed payloads, one per EventType --- diff --git a/internal/gateway/methods/agent_links.go b/internal/gateway/methods/agent_links.go index 7a65c2d0..4d12c0f5 100644 --- a/internal/gateway/methods/agent_links.go +++ b/internal/gateway/methods/agent_links.go @@ -408,10 +408,10 @@ func (m *AgentLinksMethods) invalidateLinkAgentsByID(ctx context.Context, source // --- helpers --- -// resolveAgentUUID resolves an agent identifier (either UUID or agent_key) to its -// canonical UUID via a DB lookup. Tenant-aware via store.TenantIDFromContext(ctx) -// inside agentStore.GetByID/GetByKey. Prefer resolveAgentUUIDCached for hot-path -// handlers to avoid an unnecessary DB roundtrip. +// resolveAgentUUID resolves an agent identifier (either UUID or agent_key) to +// its canonical UUID via a DB lookup. Tenant-aware via +// store.TenantIDFromContext(ctx) inside agentStore.GetByID/GetByKey. Prefer +// resolveAgentUUIDCached in hot-path handlers to avoid the extra DB roundtrip. // See docs/agent-identity-conventions.md trap zone 5. func resolveAgentUUID(ctx context.Context, agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) { if id, err := uuid.Parse(keyOrID); err == nil { @@ -436,11 +436,12 @@ type agentUUIDProvider interface { } // resolveAgentUUIDCached is the cache-aware variant of resolveAgentUUID. -// Checks the router cache first when the input is an agent_key and the Loop is -// cached — avoids a DB roundtrip on the hot path. Falls back to DB lookup on -// cache miss or when the input is a UUID string (not cached under the raw UUID -// key post-Phase-2 canonicalization). If router is nil, delegates straight to -// resolveAgentUUID. See docs/agent-identity-conventions.md trap zone 5. +// Checks the router cache first when the input is an agent_key and the Loop +// is cached — avoids a DB roundtrip on the hot path. Falls back to DB lookup +// on cache miss or when the input is a UUID string (router cache keys are +// canonicalized to `tenantID:agentKey`, so UUID inputs never hit the cache). +// If router is nil, delegates straight to resolveAgentUUID. +// See docs/agent-identity-conventions.md trap zone 5 and section 8. func resolveAgentUUIDCached(ctx context.Context, router *agent.Router, agentStore store.AgentStore, keyOrID string) (uuid.UUID, error) { // Fast path: input is agent_key and the agent is cached in the router. if router != nil { diff --git a/internal/store/pg/memory_docs.go b/internal/store/pg/memory_docs.go index cdf25db4..e56b7d32 100644 --- a/internal/store/pg/memory_docs.go +++ b/internal/store/pg/memory_docs.go @@ -477,12 +477,12 @@ func (s *PGMemoryStore) Close() error { return nil } // --- Helpers --- -// parseUUID returns the parsed UUID or a descriptive error. -// Use for every INSERT/UPDATE/UPSERT/DELETE/SELECT-WHERE path where silent nil -// would either corrupt data (FK constraints reject it, but callers see cryptic -// PG code 23503 instead of a clean Go error) OR hide bugs as empty reads / -// zero-row updates. Phase 4 migration target for all 57 CRITICAL sites. -// See docs/agent-identity-conventions.md (Phase 6). +// parseUUID returns the parsed UUID or a descriptive error. Use for every +// INSERT/UPDATE/UPSERT/DELETE and any SELECT WHERE where silent nil would +// either corrupt data or hide bugs as empty reads / zero-row updates. FK +// constraints reject bad writes at the driver layer, but errors there come +// back as cryptic PG 23503 — parseUUID catches them upstream with a clean +// Go error. See docs/agent-identity-conventions.md trap zone 3. func parseUUID(s string) (uuid.UUID, error) { id, err := uuid.Parse(s) if err != nil { @@ -492,11 +492,11 @@ func parseUUID(s string) (uuid.UUID, error) { } // parseUUIDOrNil returns the parsed UUID or uuid.Nil on failure, without -// raising an error. INTENTIONALLY silent — only use for read-only SELECT-WHERE -// paths where a no-match (empty result) is acceptable. Do NOT use for writes, -// updates, deletes, or any SELECT where empty result would hide a bug. -// Prefer parseUUID for any new code. -// See docs/agent-identity-conventions.md (Phase 6). +// raising an error. INTENTIONALLY silent — only acceptable on read-only +// SELECT WHERE paths where a no-match (empty result) is the correct +// semantics on bad input. Do NOT use for writes, updates, deletes, or any +// SELECT where an empty result would hide a bug. Prefer parseUUID for new +// code. See docs/agent-identity-conventions.md trap zone 3. func parseUUIDOrNil(s string) uuid.UUID { id, err := uuid.Parse(s) if err != nil {