Files
goclaw/docs/agent-identity-conventions.md
viettranx e6e351aca0 docs: document ACTOR vs SCOPE pattern + #915 changelog entry
- agent-identity-conventions.md: new "ActorID vs UserID in Group Chats"
  section with helper table, group behavior table, propagation chain
  diagram, group permission policy, legacy-data tolerance notes.
- 17-changelog.md: entry covering the security fix, propagation
  additions, ACTOR migration list, scope-intentional sites, tests,
  and the no-DB-migration decision with its legacy-fallback rationale.
2026-04-16 14:17:48 +07:00

24 KiB

Agent Identity Conventions — agent_key vs UUID

Dual-identity rules for agents, teams, and tenants. Read this before touching any code path that handles identifier strings, DomainEvents, SQL WHERE agent_id, router cache, or system prompts.

1. Core rule

Question to ask for every identifier:

Does this identifier ever touch the DB layer or need to be unique system-wide?

  • Yes → use UUID
  • No (display / path / template / log only) → use agent_key

Vietnamese phrasing used by the team:

"Cái identifier này có bao giờ đụng vào DB layer hoặc cần unique toàn hệ thống không? Có → UUID. Không → agent_key."

There is no third option. Every site that handles an AgentID, TenantID, or TeamID string must answer this question.

2. Decision table

2.1. MUST use UUID

Use case Reason Example
SQL WHERE/JOIN/INSERT on agent_id column DB column type is uuid episodic_summaries.agent_id, kg_entities.agent_id
Store model struct field Compile-time type safety store.EpisodicSummary.AgentID uuid.UUID
store.WithAgentID(ctx, ...) Downstream SQL query context loop_context.go
eventbus.DomainEvent.AgentID ⚠️ Consumer workers parse and query DB with it episodic_worker, semantic_worker, dreaming_worker
OpenTelemetry span attribute agent_id Cross-session tracing correlation loop_tracing.go
Tenant-scoped FK (team members, agent_links) DB constraint enforcement TeamMember.AgentID, AgentLink.SourceAgentID
Public HTTP/WS API AgentID params Stable across renames params.AgentID in heartbeat, config, teams methods
SystemPromptConfig.AgentUUID Runtime identification, not exposed to LLM Paired with AgentID (key)

2.2. MUST use agent_key

Use case Reason Example
System prompt template ("You are @X") LLM/user reads it, must be human-readable SystemPromptConfig.AgentID
Filesystem path segment agents/{X}/... Path is user-visible workspace.ResolveParams.AgentIDsanitizeSegment()
WS AgentEvent broadcast to UI UI filters and displays by name AgentEvent.AgentID
Slog log fields ("agent" key) Human-readable when grep'ing logs slog.Info(..., "agent", l.id)
store.WithAgentKey(ctx, ...) Intentional key propagation loop_context.go
tools.WithToolAgentKey(ctx, ...) Tool access identifier loop_context.go
Router cache lookup Callers have agent_key readily available router.Get(ctx, agentKey)
Channel bot identifier (Telegram @X) Users type it in chat Channels layer
Loop.ID() string method Display API loop_tracing.go

3. Trap zones

Five known identifier trap zones. All are mitigated by the agent identity hardening work (commits acbfb2e4..d3f37068). Status per zone below.

🪤 Trap 1: DomainEvent.AgentID is a string

  • Problem: field type is string; the compiler cannot enforce UUID vs key. Publishers nearby only see the type, not the semantic intent. If a publisher writes AgentID: l.id instead of l.agentUUID.String(), downstream consumers (which uuid.Parse and query DB) explode with invalid input syntax for type uuid.
  • Rule: if ANY consumer calls uuid.Parse(event.AgentID) or passes the value down to SQL WHERE agent_id = $1, the publisher MUST set a UUID string. Today every production consumer in internal/consolidation/* does exactly that.
  • Status: Mitigated by the publish-time observer in internal/eventbus/validate_agent_id.go — warns with slog.Warn("eventbus.non_uuid_agent_id", ...) on any non-UUID drift. Strict type lift to uuid.UUID is gated (Phase 5) behind wire-format audit + rolling-deploy coexistence tests.

🪤 Trap 2: Struct with both AgentID and AgentUUID

  • Problem: SystemPromptConfig has AgentID string (agent_key) + AgentUUID string (UUID). Two adjacent string fields are trivially swappable at a callsite.
  • Rule: always check field comments or downstream consumer behavior before setting either field. AgentID is rendered into the system prompt ("You are @{AgentID}"); AgentUUID is for runtime identification and never reaches the LLM.
  • Status: Mitigated. The one historical omission (memoryflush.go missing AgentUUID) is now set. See section 10 for why the omission worked by accident historically.

🪤 Trap 3: mustParseUUID silently returns uuid.Nil

  • Problem: a store helper named mustParseUUID looked like uuid.MustParse from the stdlib (which panics), but actually behaved like uuid.Parse with the error swallowed — returning uuid.Nil on bad input. 88 callsites in internal/store/pg/. Passing an agent_key in would silently write agent_id = '00000000-0000-0000-0000-000000000000' or, for reads, return an empty result / zero-row update.
  • Rule: two helpers, two contracts.
    • parseUUID(s string) (uuid.UUID, error) — use for every INSERT/UPDATE/UPSERT/DELETE and any SELECT WHERE where an empty result would hide a bug. Propagate the error. Callers fail fast with a clean Go error instead of a cryptic PG 23503 or a silently corrupted row.
    • parseUUIDOrNil(s string) uuid.UUID — honest rename of the former mustParseUUID. Intentionally silent. Only acceptable on read-only SELECT WHERE paths where a no-match on bad input is the correct semantics. Prefer parseUUID for any new code.
  • Status: Mitigated by the parseUUID refactor across internal/store/pg/. 57 critical write sites migrated. The remaining parseUUIDOrNil sites are audited and intentional.

🪤 Trap 4: Router.Get(ctx, X) accepts both UUID and agent_key

  • Problem: the router resolver parses either a UUID or an agent_key and returns the same Loop. Pre-hardening, the cache key was the raw input string — so the same agent could end up with two cache entries (one under UUID, one under agent_key), defeating invalidation. In addition, HasSuffix-based invalidation could collide with unrelated keys that happened to share a suffix.
  • Rule: cache entries are canonicalized to tenantID:agentKey after resolution, regardless of whether the caller passed UUID or key. Invalidation uses exact-segment match, not substring. IsRunning(ctx, agentKey) is tenant-aware and requires tenantID from context.
  • Status: Mitigated. See section 8 for the full cache strategy.

🪤 Trap 5: WS method params AgentID — inconsistent parsing

  • Problem: WS handlers variously used bare uuid.Parse(params.AgentID), resolveAgentUUID(...), or passed straight to the router. From the client's perspective there was no single rule for whether to send UUID or agent_key.
  • Rule: WS handlers accept both UUID and agent_key for AgentID params and resolve through a single helper. Hot-path handlers use resolveAgentUUIDCached which consults the router cache first and falls back to a DB lookup on miss. Cold-path handlers use resolveAgentUUID directly.
  • Status: Mitigated. Standardized through internal/gateway/methods/agent_links.go helpers.

4. Code review sanity checklist

For every new AgentID: assignment or new agent_id read/write, walk through in order:

Step 1 — Destination field type?

Destination type Required source
uuid.UUID UUID source only: l.agentUUID, ag.ID, store.AgentIDFromContext(ctx)
*uuid.UUID Same as above, nullable
string → Step 2

Step 2 — If destination is string, what does the consumer do with it?

Consumer behavior Required identifier
uuid.Parse(...) or passes to SQL WHERE x = $1 on a uuid column UUID string
Logs / displays / compares / renders template / builds path agent_key string
Forwards to another consumer Follow the terminal consumer

Step 3 — Double-check with grep

grep -rn "\.AgentID" <consumer files>

Look for any uuid.Parse(...) or SQL WHERE agent_id = $1 in the downstream path. If any consumer eventually touches DB, the publisher must supply a UUID.

Step 4 — Run the regression tests

go test ./internal/consolidation/... ./internal/eventbus/... ./internal/store/pg/...

Relevant tests:

  • consolidation/workers_test.go::TestEpisodicWorkerHandle_NonUUIDAgentID — rejects "goctech-leader" with a clean error.
  • consolidation/workers_test.go::TestEpisodicWorkerHandle_NonUUIDTenantID — mirror for tenant.
  • eventbus/validate_agent_id_test.go — publish-time observer coverage.
  • store/pg/parseuuid_test.go — helper contract.
  • agent/router_cache_canonicalize_test.go, router_invalidate_collision_test.go, router_isrunning_test.go — router cache strategy.
  • gateway/methods/agent_resolve_cached_test.go — cache-aware resolver.

5. How this applies to teams and tenants

The dual-identity pattern is a codebase-wide convention, not agent-specific:

Entity UUID field Key field Notes
Agent agents.id uuid agents.agent_key text Dual-tenant: same key across tenants is allowed
Team agent_teams.id uuid (no key field) Teams use UUID only; no slug or key field
Tenant tenants.id uuid tenants.tenant_slug text Slug used in URLs and onboarding wizard
Session sessions.session_key text (no separate UUID) Session key is the only identifier — safe by construction

The rules in section 2 apply to agents and tenants. For teams:

  • WithTenantID(ctx, ...) takes a uuid.UUID. Never pass a slug.
  • TeamMember.AgentID is the canonical team-membership FK and must be a UUID.
  • Slog log fields "tenant", "team" should receive the human-readable slug/key, not the UUID.

6. Post-hardening status

Trap zone Status Guardrail
Trap 1 — DomainEvent.AgentID as string Mitigated (strict type gated) eventbus/validate_agent_id.go publish observer
Trap 2 — SystemPromptConfig dual field Mitigated memoryflush.go + loop_history.go set both fields
Trap 3 — mustParseUUID silent nil Mitigated parseUUID error-propagating helper in 57 critical sites
Trap 4 — Router cache fragmentation Mitigated Canonical tenantID:agentKey keys + exact-segment invalidation
Trap 5 — WS method inconsistency Mitigated resolveAgentUUID / resolveAgentUUIDCached helpers

Phase 5 (DomainEvent strict-type lift) is gated — only starts once Phases 1-4 have been stable on dev for ≥72h with zero eventbus.non_uuid_agent_id observer warnings, and after a coexistence test covering rolling deploys. Without those signals, the runtime observer is sufficient defense.

7. Regression test coverage map

Test File Guards
TestEpisodicWorkerHandle_NonUUIDAgentID internal/consolidation/workers_test.go Trap 1 — consumer fails cleanly on bad AgentID
TestEpisodicWorkerHandle_NonUUIDTenantID internal/consolidation/workers_test.go Trap 1 — same for tenant
TestValidateAgentID_* internal/eventbus/validate_agent_id_test.go Trap 1 — publish-time observer warns on drift
TestParseUUID* / TestParseUUIDOrNil* internal/store/pg/parseuuid_test.go Trap 3 — helper contract (error vs silent)
TestRouterGet_Canonicalize* internal/agent/router_cache_canonicalize_test.go Trap 4 — UUID and key both map to same canonical cache entry
TestRouterInvalidate_SuffixCollision internal/agent/router_invalidate_collision_test.go Trap 4 — exact-segment invalidation does not collide on substring
TestRouterIsRunning_* internal/agent/router_isrunning_test.go Trap 4 — IsRunning is tenant-scoped, not tenant-blind
TestRouterGet_DualTenantSameAgentKey internal/agent/router_cache_canonicalize_test.go Dual-tenant agent_key isolation (see section 13)
TestAgentResolveCached_* internal/gateway/methods/agent_resolve_cached_test.go Trap 5 — cache-aware fast path plus DB fallback
TestVaultHandlerUpload_* internal/http/vault_handler_upload_test.go HTTP form agent_id entry point (bypass gap for owner / lite edition)
TestMemoryFlush_* internal/agent/memoryflush_test.go Trap 2 — SystemPromptConfig.AgentUUID is set

8. Router cache canonicalization strategy

Cache invariants after hardening:

  1. Key format: {tenantID}:{agentKey}. Computed AFTER the resolver returns an Agent. Inputs that arrived as UUID are translated to agentKey via the resolved record. No more dual entries.
  2. Tenant scoping: all router operations take ctx and extract tenant via store.TenantIDFromContext. Router.Get, Router.GetCached, Router.IsRunning, and InvalidateAgent all require a tenant.
  3. Invalidation: exact-segment match on the full canonical key. The previous strings.HasSuffix(entry, agentKey) approach could collide when one agent's key was a suffix of another's. Now replaced with exact match against {tenantID}:{agentKey}.
  4. IsRunning(ctx, agentKey): tenant-scoped. Pre-hardening, the check was tenant-blind and always returned false for any tenant whose agent_key collided with another tenant's loop (see section 13).

9. Batch fail-fast contract

Pre-hardening: store methods that accepted a batch (vault bulk upload, KG ingest, memory doc batch) called mustParseUUID on each row. Bad UUIDs resolved to uuid.Nil, the INSERT went through, and the DB rejected it via FK code 23503 — for the first bad row only. Successfully inserted rows stayed. Callers saw a partial success.

Post-hardening: parseUUID errors at parse time. The entire batch fails fast at the first invalid UUID, before any write happens. Net contract change:

  • Error is raised earlier, not silent-to-loud.
  • Callers that relied on partial success must validate UUIDs up front or split the batch themselves.
  • Clients using bulk operations cannot mix valid and invalid UUIDs in the same call.

Affected operations: vault bulk upload, knowledge graph batch ingest, memory documents batch upsert.

This is an intentional contract change and is documented here rather than hidden in a release note, because callers outside this repo (if any) will see the new error type.

10. memoryflush historical accident

memoryflush.go historically set only SystemPromptConfig.AgentID and not AgentUUID. This did not cause a bug because buildRuntimeSection — the function that consumes AgentUUID — runs after the CacheBoundaryMarker in the assembled system prompt. Anything below the marker is rendered fresh every turn and never feeds into the stable prefix that gets cached by the LLM provider. Missing AgentUUID simply meant an empty runtime field in the dynamic suffix. No prompt cache bust, no functional consequence.

The field is now populated unconditionally for consistency with loop_history.go, which has always set both fields. Mentioned here because anyone reading the fix is likely to wonder what broke — nothing broke, it was a latent gap that would have become a real bug if AgentUUID ever moved above the cache boundary.

11. FK as safety net

PostgreSQL and SQLite both enforce FK constraints on agent_idagents.id. This is the primary correctness guarantee against silent data corruption, not the parseUUID refactor.

PostgreSQL: all primary phase 4 target tables have an FK with CASCADE (or SET NULL for nullable columns). Verified against staging on 2026-04-11 across episodic_summaries, kg_entities, kg_relations, memory_documents, memory_chunks, vault_documents — plus 25 other agent-scoped tables. Bad UUIDs are rejected with PG error code 23503.

SQLite: PRAGMA foreign_keys = ON is applied per-connection via sqlitestore/pool.go and schema.sql. All agent_id TEXT columns declare REFERENCES agents(id) ON DELETE CASCADE. Bad input → SQLite constraint error.

Orphan scan: staging MCP scan on 2026-04-11 found zero rows with agent_id = '00000000-0000-0000-0000-000000000000' across 5 NOT-NULL target tables. Self-hosted deployments that predate the FK migrations can run scripts/scan_orphan_agent_rows.sh as a pre-flight check before upgrading.

What the parseUUID refactor adds on top of FK:

  • Earlier error detection (before the SQL round-trip).
  • A clean Go error with a descriptive message instead of a driver-layer PG 23503.
  • Coverage for UPDATE WHERE agent_id = uuid.Nil and SELECT WHERE agent_id = uuid.Nil — FK cannot help on reads or no-op updates. This is where the real silent-bug risk lived post-FK, and the refactor closes it.

If a future developer misses a parseUUID site: the result is a cryptic PG error at the driver layer, not corruption. The FK safety net still holds.

12. Telemetry tables without FK

spans, traces, usage_snapshots do not have FK on agent_id. Reason: these are append-only, high-volume telemetry tables — FK validation overhead at insert rate would be prohibitive.

Type-layer defense instead of FK:

  • All struct AgentID fields in store/pg/tracing.go, store/pg/snapshot.go, etc. are *uuid.UUID, not string.
  • Inserts go through store/base/helpers.go::nilUUID() which converts uuid.Nil → SQL NULL.
  • There is no mustParseUUID call site in telemetry store code — callers cannot pass bare strings. The compile-time contract blocks the trap entirely.

Residual risk: if a caller bypasses nilUUID() and writes uuid.Nil raw, the row lands with agent_id = '00000000-0000-0000-0000-000000000000'. Impact is scoped to analytics pollution (these tables feed dashboards, not downstream pipelines). Check with:

SELECT count(*) FROM spans WHERE agent_id = '00000000-0000-0000-0000-000000000000'::uuid;

If the count ever rises above zero on production, it is a caller bug in the telemetry emission path — not a store-layer regression.

13. Dual-tenant agent_key

agent_key is unique only within a tenant. The same agent_key can exist in multiple tenants with different UUIDs. Example from staging (2026-04-11): tieu-ho exists in both the Master tenant and the Việt Org tenant under different agents.id values.

The unique index is:

CREATE UNIQUE INDEX agents_tenant_key_unique
  ON agents (tenant_id, agent_key)
  WHERE deleted_at IS NULL;

Consequences for code:

  • Never use bare agent_key as a global identifier. It is only unique inside a tenant.
  • Router.Get(ctx, "tieu-ho") requires tenantID from ctx. Without tenant scoping, the lookup is ambiguous across tenants.
  • Cache entries are keyed as {tenantID}:{agentKey} (section 8).
  • Log formatting: prefer {"agent": "tieu-ho", "tenant": "master"} together, or just the UUID. Never log agent_key alone without a tenant.
  • When designing new tables with agent_key columns, the unique index must include tenant_id. Scoping to tenant_id prevents the same error class in future features.

Note on teams: Unlike agents, agent_teams table uses UUID only. There is no team_key or slug field. Always reference teams by id (UUID). Teams are not dual-identity.

Appendix — TL;DR one-liner

  • Use l.agentUUID.String() when setting DomainEvent.AgentID, store model fields, SQL query parameters, OTel span attributes, FK constraints, and any tenant-scoped unique key.
  • Use l.id when logging, emitting AgentEvent for the UI, rendering the system prompt, building filesystem paths, setting the key context (WithAgentKey), and doing router lookups.

ActorID vs UserID in Group Chats

A second identity pattern complements agent identity: who is acting vs which scope the action belongs to. The pattern mirrors agent UUID/key but applies to end users in group contexts (Telegram, Discord, Feishu, Zalo).

The two identities

Identity Context key Helper Meaning
SCOPE — namespace / memory UserIDKey store.UserIDFromContext(ctx) group:telegram:<chatID> in Telegram groups; guild:<guildID>:user:<senderID> in Discord guilds; sender ID in DMs
ACTOR — acting principal SenderIDKey store.SenderIDFromContext(ctx) Always the individual sender's numeric ID (never group-scoped)
(combined) store.ActorIDFromContext(ctx) SenderID if set, else falls back to UserID

When to use each

Purpose Helper Why
Memory / KG / session key UserIDFromContext / MemoryUserID / KGUserID Memory is per-group by design — all members share conversational context
File / path scope UserIDFromContext Per-group workspace; group members share files
Permission check SenderIDFromContext Checks attribute to the real user, not the group
Audit trail (initiated_by, event UserID) ActorIDFromContext Traces the real user action across wrappers
Ownership (OwnerID, creator fields) ActorIDFromContext A skill published in a group belongs to the individual user
Role / RBAC ActorIDFromContext Role applies to the human, not the group

Group behavior table

Context UserID SenderID ActorID
Telegram DM sender numeric sender numeric sender numeric
Telegram group group:telegram:<chatID> sender numeric sender numeric
Discord DM sender snowflake sender snowflake sender snowflake
Discord guild guild:<guildID>:user:<senderID> sender snowflake sender snowflake
HTTP API HTTP user ID (empty) HTTP user ID
Cron / subagent system ctx (inherited / empty) (empty or propagated) same as SenderID, else UserID

Propagation through wrappers (#915)

When a tool wrapper synthesizes an inbound message (subagent announce, delegate announce, teammate dispatch, session_send), the synthetic SenderID carries routing identity (e.g. subagent:<taskID>) — NOT a real user. The real acting sender must travel through InboundMessage.Metadata under tools.MetaOriginSenderID, and the next-turn builder copies it to RunRequest.SenderID. Without this propagation:

  • Permission checks against bus.IsInternalSender(...) prefixes → denied (synthetic senders never match a grant)
  • Empty sender → denied (CheckFileWriterPermission group-context fail-closed policy)

Code path:

SubagentTask.OriginSenderID
  → AnnounceMetadata.OriginSenderID
  → InboundMessage.Metadata[MetaOriginSenderID]
  → subagentAnnounceRouting.SenderID
  → RunRequest.SenderID
  → loop_context.go:WithSenderID(ctx, …)
  → SenderIDFromContext(ctx) in permission checks

Same chain for delegate (delegate_tool.announceToParent) and teammate dispatch (team_tool_dispatch.go).

Group permission check policy

store.CheckFileWriterPermission / store.CheckCronPermission in group or guild context (UserID starts with group: or guild:):

  • empty SenderIDDENY (system context must not gain write access silently)
  • synthetic-prefix SenderIDDENY (subagent:, notification:, teammate:, system:, ticker:, session_send_tool)
  • real numeric SenderID → DB lookup; DENY if no file_writer allow grant for this sender
  • DB error → fail-open (availability over strictness)

In DM / HTTP / cron-direct context (no group/guild prefix): always allow. No per-user writer gate applies.

Legacy-data tolerance for skill ownership

Skills / cron / delegate audit trails created before #915 migration store UserIDFromContext values (possibly group:* prefixed) in owner_id / user_id columns. Ownership checks at skill_manage.go (patch/delete) accept either ActorIDFromContext (new) or UserIDFromContext (legacy). This lets existing group-scoped rows remain accessible without a destructive backfill. When a user re-publishes a skill, the new row uses actor ownership (tighter by default).

  • internal/store/context.go — helper definitions
  • internal/store/config_permission_store.go — group permission policy
  • internal/tools/subagent_spawn.go, subagent_exec.go — subagent propagation
  • internal/tools/delegate_tool.go — delegate propagation
  • cmd/gateway_subagent_announce_queue.go, gateway_consumer_handlers.go — re-ingress reconstruction
  • tests/integration/telegram_group_write_file_permission_test.go — regression fixtures

When in doubt, walk the four-step checklist in section 4.