- Status: Proposed
- Date: 2026-04-26
- Supersedes: portions of internal "G4/G7 substrate" usage notes
- Related: ADR-0002 (Enterprise Productization), Phase 0/G4/G7 PRs (#82, #83, #84)
Context
Phase 0 + Phase 1 G4 + Phase 1 G7 made what an apply does honest: authority, canapply, edit_authority, hash-based ApplyEvidence, rollback report. But _who is allowed to call and what actually happened durably are still implicit:
- MCP stdio = anyone-with-process-access calls every mutation tool.
- HTTP
--auth-tokenis binary; no role granularity. - ApplyEvidence is response-only. If the caller does not persist it, the audit trail is lost.
- ApplyStatus is a 3-state enum (Applied / RolledBack / NoOp); the full mutation lifecycle (preview → verify → apply → committed → audited → rolled_back / failed) is undocumented.
- After a mutation, embedding / bm25 / LSP caches are not informed,
so the next
find_*call may return stale data and an agent may build the next decision on falsified state.
The four gaps are not independent. Together they form a single missing substrate: trustable mutation operation — auth + audit + state + cache-invalidation as one consistent contract.
Decision
Introduce a Mutation Trust Substrate (Phase 2 scope) that externalises the four guarantees as a single dispatch-pipeline gate:
- Role gate — every mutation tool call must pass a
(principal, role) → allowed_toolscheck before reaching the handler. - Durable audit sink — every mutation tool call writes one
append-only row to
<project>/.codelens/audit_log.sqlitewith transaction id, principal, tool, args hash, apply status, evidence hash, error message. - Mutation lifecycle state machine — eight states + named
transitions, one row per transition, queryable via
audit_log_querytool. - Cache invalidation contract — every mutation response carries
invalidated_paths; engine cache layers (embedding / bm25 / LSP / SQLite symbols) self-invalidate on next read.
This is not a new abstraction layer in front of G4/G7 substrates. It is dispatch-pipeline policy plus a single-table SQLite log.
Decision Details
1. Role Model (3-tier MVP)
pub enum Role {
ReadOnly, // analyze_*, find_*, get_*, semantic_search
Refactor, // ReadOnly + 9 raw_fs primitives + LSP rename apply +
// safe_delete_apply + apply_workspace_edit_value
Admin, // Refactor + audit_log_query + job control
}
Configuration: <project>/.codelens/principals.toml (project-local
override) or ~/.codelens/principals.toml (user-global default).
# principals.toml
[default]
role = "Refactor" # used when no principal id is bound
[principal."user@example.com"]
role = "Admin"
[principal."ci-bot"]
role = "ReadOnly"
Principal binding source (priority order):
- HTTP
Authorization: Bearer <jwt>— claimsubis the principal id - HTTP
X-Codelens-Principalheader (only when no JWT, dev mode) - stdio:
CODELENS_PRINCIPALenv var - fallback:
defaultprincipal in principals.toml
Enforcement is in dispatch.rs: one call to
enforce_role(tool_name, principal_role)? before handler invocation.
On reject: Err(CodeLensError::PermissionDenied), JSON-RPC error code
-32008 (deviation: -32004 was already used by IndexNotReady),
audit row written with apply_status="denied".
2. Durable Audit Sink
Store: <project>/.codelens/audit_log.sqlite. Single append-only table:
CREATE TABLE IF NOT EXISTS audit_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
transaction_id TEXT NOT NULL,
timestamp_ms INTEGER NOT NULL,
principal TEXT,
tool TEXT NOT NULL,
args_hash TEXT NOT NULL, -- sha256 of canonicalised args JSON
apply_status TEXT NOT NULL, -- enum: see §3 transition table
state_from TEXT, -- previous state, NULL for first row
state_to TEXT NOT NULL, -- new state
evidence_hash TEXT, -- sha256 of ApplyEvidence JSON, NULL if N/A
rollback_restored INTEGER, -- 0/1 if status=rolled_back, else NULL
error_message TEXT,
session_metadata TEXT -- JSON: project_scope/surface/client_name/...
);
CREATE INDEX IF NOT EXISTS idx_audit_log_tx ON audit_log(transaction_id);
CREATE INDEX IF NOT EXISTS idx_audit_log_ts ON audit_log(timestamp_ms);
session_metadata is the carve-out that absorbed the legacy
mutation-audit.jsonl intent record (Phase 2 close part 4): operators
get one queryable store instead of two. The migration from v1 to v2
adds the column in place; existing audit logs round-trip without data
loss.
Retention: on every AuditSink::open (i.e. once per AppState
lifetime — first call to audit_sink()), rows older than
CODELENS_AUDIT_RETENTION_DAYS (default 90) are deleted and the
file is VACUUM-ed. Setting the env var to 0 or any negative
integer disables retention. gzip archival to a sibling
audit_archive/ directory was scoped out — the immediate need is
disk-fill protection; cold-archive shipping is left to operators
(rsync / S3 sync of the SQLite file).
Write API (engine or mcp module — see §6):
pub struct AuditSink { /* internal */ }
impl AuditSink {
pub fn open(project: &ProjectRoot) -> anyhow::Result<Self>;
pub fn write(&self, record: &AuditRecord) -> anyhow::Result<()>;
pub fn query(
&self,
transaction_id: Option<&str>,
since_ms: Option<i64>,
limit: usize,
) -> anyhow::Result<Vec<AuditRecord>>;
}
3. Mutation Lifecycle State Machine
The dispatch entry decides between two intermediate states based on
where the call exits the pipeline. Pre-handler rejections (role gate)
short-circuit to Denied without ever entering the substrate.
role_gate_denied
(request) ─────────────────────► Denied (terminal)
(request)
│ role_gate_passed
▼
Verifying
┌────┴─────┐
verify │ │ verify_passed
failed │ ▼
│ Applying
│ ┌────┴────┐
│ │ │ apply_succeeded
│ │ ▼
│ │ Audited (terminal — Hybrid "applied" or "no_op")
│ │
│ │ apply_failed_restored
│ ▼
│ RolledBack (terminal — Hybrid "rolled_back")
▼
Failed (terminal — handler Err, no on-disk mutation
OR apply_failed_lost)
#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
pub enum LifecycleState {
// Intermediate (recorded as `state_from` in audit rows)
Verifying,
Applying,
// Terminal (recorded as `state_to` in audit rows)
Audited,
RolledBack,
Failed,
Denied,
}
Each call writes one audit-log row whose state_from/state_to pair
identifies which path through the machine the call traversed.
Terminal states: Audited, RolledBack, Failed, Denied. The
agent reads back via audit_log_query(transaction_id) to recover the
outcome.
Deviation from earlier draft. Prior versions of this ADR enumerated
9 states (Drafted, Previewed, Committed, plus the 6 above). Those
3 intermediates were never wired into a transition; the substrate
collapses preview/draft into the apply-time Verifying capture and
treats Committed as the same row as Audited (one row per call,
written after substrate write succeeds). They were removed for
self-consistency with the architecture rule "no dead variants".
JSON-RPC code deviation. §1 specified -32004 for
PermissionDenied; that code is already used by IndexNotReady. The
shipped error returns -32008 instead. The semantics (pre-handler
denial, no on-disk effect, Denied row written) are unchanged.
4. Cache Invalidation Contract
Every mutation tool response must include:
"invalidated_paths": ["src/foo.py", "src/bar.py"]
Each engine cache layer self-invalidates from apply_post_mutation
in mcp dispatch — before the response is returned to the agent —
so the agent's next find_* / bm25_* / semantic_search call sees
fresh data.
| Cache layer | Invalidation point | Trigger |
|---|---|---|
GraphCache (PageRank) |
state.graph_cache().invalidate() |
every mutation |
| Symbol DB | state.symbol_index().refresh_file(path) |
per-path tree-sitter reindex |
| BM25 / FTS5 | state.symbol_index().db().invalidate_fts() |
meta marker reset → lazy rebuild |
| Embedding index | EmbeddingEngine::index_changed_files(...) |
only when active or on-disk index lives |
| LSP session | next prepare_document auto-emits didChange |
lazy — handled inside LspSession |
| Recent preflights | state.clear_recent_preflights() |
every mutation |
Direct calls vs. a trait. An earlier draft proposed a
CacheInvalidator trait with four implementations registered against
the dispatch layer. The shipped substrate uses direct method calls
because there is exactly one caller (apply_post_mutation) and
exactly four cache layers; the abstraction would buy nothing today.
Reserving the trait for the day a fifth layer (or a second caller)
appears keeps the boundary honest.
Architecture Rules Compliance
| Rule | Compliance |
|---|---|
| 초기 버전은 모놀리식 우선 | ✅ AuditSink + role gate live in mcp crate, no new service |
| 역할 기반 권한은 화면/액션/API 모두 명시 | ✅ Role enum gates dispatch entry; principals.toml is the config surface |
| 감사 로그가 필요한 액션은 반드시 기록 | ✅ all 11 mutation tools + LSP rename apply + safe_delete_apply write rows |
| 상태 전이는 enum과 이벤트 기준으로 문서화 | ✅ LifecycleState enum (6 states: 2 intermediate + 4 terminal) + transitions in §3 |
| 새 추상화는 중복 제거가 입증된 경우에만 추가 | ✅ AuditSink replaces ad-hoc response-only evidence; cache invalidation uses direct method calls (no trait — single caller, fixed set of layers) |
| 다이어그램은 C4 + dynamic flow 2종 유지 | ✅ updated in docs/architecture.md (separate PR) |
Diagrams
C4 — Container view (delta from current)
┌──────────────────────────────────────────────────────────────┐
│ AI Coding Agent (Claude Code / Cursor / Codex) │
└──────┬───────────────────────────┬───────────────────────────┘
│ stdio (CODELENS_PRINCIPAL)│ HTTP (Bearer or X-Principal)
▼ ▼
┌────────────────────────────────────────────────┐
│ codelens-mcp │
│ ┌──────────────────────────────────────┐ │
│ │ dispatch.rs │ │
│ │ 1. principal_resolve (NEW) │ │
│ │ 2. role_gate (NEW) │ │
│ │ 3. schema_validate │ │
│ │ 4. handler invoke │ │
│ │ 5. cache_invalidate (NEW) │ │
│ │ 6. audit_record (NEW) │ │
│ └──────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ AppState │
│ ├─ AuditSink ──► .codelens/audit_log.sqlite│
│ ├─ principals ──► principals.toml │
│ └─ CacheInvalidators (engine-backed) │
└────────────────────────────────────────────────┘
│ in-process
▼
┌──────────────────────────┐
│ codelens-engine │
│ - edit_transaction │
│ - retrieval / lsp / bm25│
│ - CacheInvalidator impls│
└──────────────────────────┘
Dynamic flow — Mutation with Trust Substrate
Agent dispatch AppState engine audit_log caches
│ │ │ │ │ │
│─tools/call►│ │ │ │ │
│ │── principal_resolve ──┤ │ │ │
│ │── role_gate ──────────┤ │ │ │
│ │ (deny? → audit row state_to=Denied, return -32008) │ │
│ │ │
│ │── tool dispatch ──────────────────────►│ │ │
│ │ │── apply ──►(disk) │
│ │ │── ApplyEvidence│ │
│ │◄──(content, evidence, invalidated_paths)─ │ │
│ │ │
│ │── cache_invalidate(paths) ─────────────────────────────────────────►│
│ │ (Embedding/Bm25/Lsp/SymbolDb self-clear for those paths) │
│ │ │
│ │── audit_record(state_from=Applying, state_to=Audited)──►│ │
│ │ (Hybrid "applied"/"no_op" → Audited; "rolled_back" → RolledBack; │
│ │ handler Err → state_from=Verifying, state_to=Failed) │
│ │ │
│◄─response──│ (apply_status, transaction_id, evidence, invalidated_paths) │
Phase 2 PR Breakdown
| PR | Scope | LOC est. |
|---|---|---|
| P2-A | AuditSink foundation: SQLite schema, write/query API, 1 mutation wiring (proof of life) | ~500 |
| P2-B | Audit wiring for remaining 10 mutation entry points (G7 9 + LSP rename + safe_delete_apply) | ~400 |
| P2-C | Role gate + principals.toml loader + dispatch enforcement + denied-row audit | ~400 |
| P2-D | LifecycleState enum + state-transition events + audit_record per transition | ~300 |
| P2-E | CacheInvalidator trait + engine implementations (Embedding/Bm25/Lsp/SymbolDb) + dispatch wiring | ~600 |
| P2-F | audit_log_query tool + JSON-RPC error code -32008 docs + retention/rotation |
~300 |
Each PR stands alone, cargo green, has a single observable contract. Stacked on each other in this order.
Out of Scope (deferred)
- G5 runtime capability probing (separate Phase, parallel)
- G7b move_symbol 2-file atomic (separate Phase, parallel)
- Cross-process file lock (Phase 3)
- File-snapshot rollback (Phase 3)
- Multi-tenant principal store (current scope: file-based, single-tenant)
Acceptance Signals
This ADR is succeeding when:
- every mutation tool response carries
invalidated_paths - every mutation tool call (incl. denied) has exactly one row in
audit_logper state transition cargo test --features http -p codelens-mcpexercises principals.toml loading, role gate enforcement, audit row writing, and cache invalidation in a single integration test- removing the audit log file does not corrupt next-startup behaviour (sink re-creates schema)
- the agent can call
audit_log_query(transaction_id)and recover the full state-transition trail of a past mutation
Consequences
Positive
- enterprise readiness for "who did what, when, with what authority"
- agent-recoverable mutation history without external persistence
- consistent stale-cache prevention across mutation surfaces
- explicit state machine simplifies failure-mode reasoning
Negative
- one extra SQLite write per mutation (~0.5–2 ms hot-path cost)
- principals.toml requires operator action for non-default deployments
- six PRs to land Phase 2 fully
Risk: Audit Skip Bypass
If a future mutation tool is added but bypasses dispatch (direct
handler call), it can skip auditing. Mitigation: dispatch.rs is the
only sanctioned entry point; tests assert that every tool registered
in tool_defs goes through the audit step (assertion via mock sink).