ADR-0003: AI Interaction Logging
Proposed Issue TrackerDate: 2026-02-11 | Last Revised: 2026-03-08
Compliance: ISO 27001 (A.8.15 Logging, A.15.1 External Provider Relationships, A.8.6 Capacity Management)
Context
The Issue Tracker integrates AI across three distinct channels:
- Outbound AI calls — The application calls Claude's API to generate user stories and drive multi-turn feature planning conversations. These cross the system boundary to an external data processor.
- Inbound MCP tool invocations — External AI actors (Claude Code, IDE integrations) call the application's MCP server via OAuth tokens. 20 tools expose operations on issues, comments, time logs, epics, sprints, and branches.
- Inbound AI run webhooks — GitHub Actions AI runs report completion with cost, duration, and outcome data.
An ISO 27001 compliance review identified that none of these channels produce an auditable trail. An auditor cannot answer:
- How many AI-generated stories were created, by whom, and when
- Which MCP tools were invoked, by which token holder, and with what outcome
- Whether AI run cost data has been tampered with after the fact
Decision
Three-Channel Logging Architecture
Each AI interaction channel gets a dedicated append-only, hash-chained log table. All three follow the patterns established in Audit Logging: per-table hash chains, explicit Action-level logging, and RequestContext for forensic metadata.
Channel 1: Outbound AI Calls (ai_outbound_logs)
Logs every call from the application to an external AI provider.
Trigger points:
GenerateStoryAction::execute()— structured story generationAgentGenerateStoryAction::execute()— agentic story generation with toolsFeaturePlannerChatAction::execute()— multi-turn feature planning conversation
Schema
-- ai_outbound_logs
id BIGINT UNSIGNED -- Auto-increment primary key
actor_type TINYINT UNSIGNED -- ActorType enum (consistent with ADR-0001)
user_id BIGINT UNSIGNED -- FK to users (nullable for system actors)
user_name VARCHAR -- Point-in-time snapshot
user_email VARCHAR -- Point-in-time snapshot
user_role VARCHAR -- Point-in-time snapshot
feature VARCHAR NOT NULL -- Feature identifier (e.g., 'story_generation')
provider VARCHAR NOT NULL -- AI provider (e.g., 'anthropic')
model VARCHAR NOT NULL -- Model used (e.g., 'claude-sonnet-4-5-20250929')
project_id BIGINT UNSIGNED -- Project context (nullable)
input_tokens INT UNSIGNED -- Token count sent (nullable)
output_tokens INT UNSIGNED -- Token count received (nullable)
response_time_ms INT UNSIGNED -- Round-trip time in milliseconds (nullable)
status VARCHAR NOT NULL -- 'success' / 'error' / 'timeout'
error_message VARCHAR -- Error details (nullable)
ip_address VARCHAR -- Request origin
user_agent VARCHAR -- Client identification
url VARCHAR -- Request method + path
hash CHAR(64) NOT NULL -- SHA-256 hash for tamper detection
created_at TIMESTAMP NOT NULL -- Immutable (no updated_at)What is NOT stored: Actual user input content (may contain sensitive business information), AI response content (large, not needed for compliance), or system prompts (stored separately, traceable via feature + project_id).
AiService prerequisite: AiService must be modified to return a response DTO that includes both the content and token usage metadata. All four methods (generate(), generateStructured(), generateWithTools(), generateConversational()) must return the DTO.
Channel 2: MCP Tool Invocations (ai_mcp_logs)
Logs every MCP tool call made by an external AI actor against the application.
Trigger point: Each MCP tool invocation — explicit per-tool logging, consistent with doctrine.
Schema
-- ai_mcp_logs
id BIGINT UNSIGNED -- Auto-increment primary key
user_id BIGINT UNSIGNED -- FK to users (NOT NULL — MCP always has token owner)
user_name VARCHAR NOT NULL -- Point-in-time snapshot
user_email VARCHAR NOT NULL -- Point-in-time snapshot
user_role VARCHAR NOT NULL -- Point-in-time snapshot
token_id BIGINT UNSIGNED -- OAuth token used for authentication
tool_name VARCHAR NOT NULL -- MCP tool invoked (e.g., 'CreateIssueTool')
project_id BIGINT UNSIGNED -- Project context (nullable)
status VARCHAR NOT NULL -- 'success' / 'error'
error_message VARCHAR -- Error details (nullable)
ip_address VARCHAR -- Request origin
user_agent VARCHAR -- Client identification
url VARCHAR -- Request method + path
hash CHAR(64) NOT NULL -- SHA-256 hash for tamper detection
created_at TIMESTAMP NOT NULL -- Immutable (no updated_at)Design notes:
user_idis NOT NULL — MCP calls always have an authenticated token ownertoken_idenables "show me everything this token did" queries for security investigations- Tool parameters are NOT stored — they may contain sensitive data. The tool name + project context is sufficient for compliance; detailed parameters can be reconstructed from entity audit logs
- MCP resource reads are NOT logged — only tool invocations (mutations + searches) are in scope
- The 9 agent tools used by the Feature Planner are NOT in scope — they are internal tools covered by Channel 1's logging of the parent Action
Channel 3: AI Run Completions (ai_run_logs)
Logs every AI run completion as an immutable compliance record. Complements the mutable AiRun model with a tamper-resistant audit entry.
Trigger point: CompleteAiRunAction::execute() — after the business transaction commits.
Schema
-- ai_run_logs
id BIGINT UNSIGNED -- Auto-increment primary key
actor_type TINYINT UNSIGNED -- ActorType enum (GitHubWebhook for completions)
user_id BIGINT UNSIGNED -- FK to users (nullable)
user_name VARCHAR -- Point-in-time snapshot
user_email VARCHAR -- Point-in-time snapshot
user_role VARCHAR -- Point-in-time snapshot
ai_run_id BIGINT UNSIGNED -- The AiRun being completed (NO FK — survives deletion)
issue_id BIGINT UNSIGNED -- Issue context (NO FK — survives deletion)
project_id BIGINT UNSIGNED -- Project context (NO FK — survives deletion)
status TINYINT UNSIGNED -- AiRunStatusEnum (Completed/Failed)
cost_usd DECIMAL(8,4) -- Reported cost in USD (nullable)
duration_seconds INT UNSIGNED -- Time from started_at to completed_at (nullable)
branch_name VARCHAR -- Branch created (nullable)
pr_url VARCHAR -- Pull request URL (nullable)
error_message VARCHAR -- Error details (nullable)
ip_address VARCHAR -- Request origin (webhook caller)
user_agent VARCHAR -- Client identification
url VARCHAR -- Request method + path
hash CHAR(64) NOT NULL -- SHA-256 hash for tamper detection
created_at TIMESTAMP NOT NULL -- Immutable (no updated_at)Design notes:
ai_run_id,issue_id,project_idhave NO FK constraints — the audit log must survive deletion of referenced entities (consistent with Audit Logging)cost_usdis the immutable compliance record of what was reported. TheAiRunmodel'scost_usdis mutable business state.
Shared Infrastructure
All three tables reuse existing infrastructure from Audit Logging:
AuditLogWriter— Hash computation and record writingRequestContext— IP, user agent, URL captureActorTypeEnum— User, Scheduler, Cli, GitHubWebhook- Same configurable hash seed, same append-only rules, same
CHECKconstraints
Table Rules (All Three Tables)
Consistent with Audit Logging:
- Append-only — no UPDATE or DELETE operations
- No
updated_at— records are immutable - No
SoftDeletes— logs cannot be deleted - Per-table hash chains — same algorithm, sequential within each table
- CHECK constraints — actor integrity enforcement where applicable
Architecture Test Enforcement
// Outbound AI Actions must inject the AI outbound logger
it('outbound AI actions log all interactions', function () {
// Verify GenerateStoryAction, AgentGenerateStoryAction,
// FeaturePlannerChatAction depend on logger
});
// MCP tools must inject the MCP logger
it('MCP tools log all invocations', function () {
// Verify all 20 MCP tool classes depend on logger
});
// AI log models are append-only
it('AI log models have no update or delete methods', function () {
// Same pattern as audit logging append-only test
});Implementation Sequence
- AiService modification — Return response DTO with content + token usage
- Channel 1:
ai_outbound_logs— Migration, model, logger, integrate into 3 Actions - Channel 3:
ai_run_logs— Migration, model, logger, integrate intoCompleteAiRunAction - Channel 2:
ai_mcp_logs— Migration, model, per-tool logging across 20 MCP tools
Channel 2 is last because it touches the most files and is least urgent (MCP mutations already produce entity audit logs — the MCP log adds "who invoked via MCP" metadata).
What This ADR Does NOT Cover
These concerns are out of scope — already resolved or separate concerns:
- Rate limiting — Already implemented (
throttle:story,throttle:mcp) - Input/output sanitization — Implemented in story generation Actions
- Prompt injection hardening — Implemented via prompt injection guards
Options Considered
| Option | Verdict | Reason |
|---|---|---|
| Single table with nullable columns per channel | Rejected | Schema becomes a forest of NULLs — each channel has fundamentally different columns. Violates per-entity table pattern. |
| Per-channel tables with per-table hash chains | Accepted | Tight schemas, no cross-channel contention, independently verifiable chains. |
| Reuse existing audit tables | Rejected | AI interactions have different data requirements than mutation audits. Forcing AI data into old_values/new_values JSON is awkward. |
Consequences
Positive
- ISO 27001 compliance across all three AI interaction channels
- Per-channel tables provide tight schemas with no NULL pollution
- Token usage tracking enables capacity management without estimated cost complexity
- MCP tool invocation audit trail enables security investigations
- Immutable AI run records make cost data tamper-resistant
- Reuses existing audit infrastructure
Negative
- Three new tables, three new models, three new logger integrations
- AI service must be modified to expose token usage metadata
- 20 MCP tool classes each require logger injection — repetitive but doctrinally consistent
Risks
- If the AI SDK doesn't expose token usage, those columns will be NULL until SDK support is added (columns are nullable by design)
- Hash chain contention under high MCP volume is possible but unlikely for a project management tool