- 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.
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.AgentID → sanitizeSegment() |
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 writesAgentID: l.idinstead ofl.agentUUID.String(), downstream consumers (whichuuid.Parseand query DB) explode withinvalid input syntax for type uuid. - Rule: if ANY consumer calls
uuid.Parse(event.AgentID)or passes the value down to SQLWHERE agent_id = $1, the publisher MUST set a UUID string. Today every production consumer ininternal/consolidation/*does exactly that. - Status: Mitigated by the publish-time observer in
internal/eventbus/validate_agent_id.go— warns withslog.Warn("eventbus.non_uuid_agent_id", ...)on any non-UUID drift. Strict type lift touuid.UUIDis gated (Phase 5) behind wire-format audit + rolling-deploy coexistence tests.
🪤 Trap 2: Struct with both AgentID and AgentUUID
- Problem:
SystemPromptConfighasAgentID string(agent_key) +AgentUUID string(UUID). Two adjacentstringfields are trivially swappable at a callsite. - Rule: always check field comments or downstream consumer behavior before setting either field.
AgentIDis rendered into the system prompt ("You are @{AgentID}");AgentUUIDis for runtime identification and never reaches the LLM. - Status: Mitigated. The one historical omission (
memoryflush.gomissingAgentUUID) 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
mustParseUUIDlooked likeuuid.MustParsefrom the stdlib (which panics), but actually behaved likeuuid.Parsewith the error swallowed — returninguuid.Nilon bad input. 88 callsites ininternal/store/pg/. Passing anagent_keyin would silently writeagent_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 everyINSERT/UPDATE/UPSERT/DELETEand anySELECT WHEREwhere 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 formermustParseUUID. Intentionally silent. Only acceptable on read-onlySELECT WHEREpaths where a no-match on bad input is the correct semantics. PreferparseUUIDfor any new code.
- Status: Mitigated by the
parseUUIDrefactor acrossinternal/store/pg/. 57 critical write sites migrated. The remainingparseUUIDOrNilsites 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:agentKeyafter resolution, regardless of whether the caller passed UUID or key. Invalidation uses exact-segment match, not substring.IsRunning(ctx, agentKey)is tenant-aware and requirestenantIDfrom 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
AgentIDparams and resolve through a single helper. Hot-path handlers useresolveAgentUUIDCachedwhich consults the router cache first and falls back to a DB lookup on miss. Cold-path handlers useresolveAgentUUIDdirectly. - Status: Mitigated. Standardized through
internal/gateway/methods/agent_links.gohelpers.
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 auuid.UUID. Never pass a slug.TeamMember.AgentIDis 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:
- Key format:
{tenantID}:{agentKey}. Computed AFTER the resolver returns anAgent. Inputs that arrived as UUID are translated toagentKeyvia the resolved record. No more dual entries. - Tenant scoping: all router operations take
ctxand extract tenant viastore.TenantIDFromContext.Router.Get,Router.GetCached,Router.IsRunning, andInvalidateAgentall require a tenant. - 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}. IsRunning(ctx, agentKey): tenant-scoped. Pre-hardening, the check was tenant-blind and always returnedfalsefor 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_id → agents.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.NilandSELECT 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
AgentIDfields instore/pg/tracing.go,store/pg/snapshot.go, etc. are*uuid.UUID, notstring. - Inserts go through
store/base/helpers.go::nilUUID()which convertsuuid.Nil → SQL NULL. - There is no
mustParseUUIDcall 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_keyas a global identifier. It is only unique inside a tenant. Router.Get(ctx, "tieu-ho")requirestenantIDfrom 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 logagent_keyalone 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 settingDomainEvent.AgentID, store model fields, SQL query parameters, OTel span attributes, FK constraints, and any tenant-scoped unique key. - Use
l.idwhen logging, emittingAgentEventfor 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 (
CheckFileWriterPermissiongroup-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
SenderID→ DENY (system context must not gain write access silently) - synthetic-prefix
SenderID→ DENY (subagent:,notification:,teammate:,system:,ticker:,session_send_tool) - real numeric
SenderID→ DB lookup; DENY if nofile_writerallow 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).
Related code
internal/store/context.go— helper definitionsinternal/store/config_permission_store.go— group permission policyinternal/tools/subagent_spawn.go,subagent_exec.go— subagent propagationinternal/tools/delegate_tool.go— delegate propagationcmd/gateway_subagent_announce_queue.go,gateway_consumer_handlers.go— re-ingress reconstructiontests/integration/telegram_group_write_file_permission_test.go— regression fixtures
When in doubt, walk the four-step checklist in section 4.