Skip to content

Session ownership provenance — L3→L2 caller tracking and resume guard #2020

@Trecek

Description

@Trecek

Problem

When an L3 orchestrator dispatches an L2 food truck session, there is no record of which L3 session spawned which L2 session. This makes it impossible to enforce ownership constraints on resume — any session can pass any resume_session_id to dispatch_food_truck, including its own L3 session ID (which is what caused the #2010 failure, see #2019).

The system needs:

  1. Session ownership provenance: When an L2 is launched from an L3, record which L3 called it
  2. Resume ownership enforcement: Only the owning L3 (or a successor with the same kitchen_id) can resume an L2
  3. PreToolUse guard: Block dispatch_food_truck from accepting resume_session_id values that aren't L2 food truck sessions owned by the caller
  4. Formalized provenance store: Session ownership must be a first-class cross-platform concern, not a side-effect of diagnostic logs

Current State — What Exists Today

Provenance Fields in sessions.jsonl

sessions.jsonl (execution/session_log.py:417-461) records per-session:

Field Present Links To
session_id Yes The L2 Claude Code session UUID
kitchen_id Yes The kitchen that was open when the L2 ran
campaign_id Yes (fleet only) Equals kitchen_id for fleet dispatches; empty for recipe steps
dispatch_id Yes (fleet only) Per-dispatch UUID from _run_dispatch
order_id Yes Per-issue/order identifier
recipe_name Yes Which recipe was running
step_name Yes Which step within the recipe
caller_session_id NO The L3 Claude Code session UUID that called dispatch_food_truck — never recorded

Fleet DispatchRecord (fleet/state.py:50)

Field Present Actual Content
l3_session_id Yes The L2 food truck's Claude Code session UUID (naming is misleading — "l3" refers to "third Claude in the chain", not the L3 orchestrator)
l3_pid Yes The L2 subprocess PID
l3_starttime_ticks Yes For liveness detection (Linux only)
l3_boot_id Yes For liveness detection (Linux only)
Caller/parent session ID NO Not recorded

Platform Reality

sessions.jsonl is fully cross-platform — written on both Linux and macOS (session_log.py:64-72 handles both). Only proc_trace.jsonl (process snapshots from /proc) is Linux-only. The ownership provenance gap is not a platform limitation — it's a missing data field.

Proposed Design

Part 1: Record caller_session_id in Session Provenance

Where the caller's session ID is available:

  • In PreToolUse hooks: data["session_id"] is the Claude Code session UUID of the caller
  • In dispatch_food_truck tool: the MCP server can access the caller session from the request context
  • In _run_dispatch (fleet/_api.py): the tool_ctx has kitchen_id but not the caller's Claude session ID

Changes needed:

  1. Thread caller_session_id through dispatch_food_truckexecute_dispatch_run_dispatch → executor → flush_session_log()
  2. Add caller_session_id field to sessions.jsonl index entry (session_log.py:417-461)
  3. Add caller_session_id to DispatchRecord in fleet/state.py
  4. Add caller_session_id to summary.json per-session record

Part 2: Formalize Session Provenance Store

Suggested approach — dedicated provenance index: Create a new session_provenance.jsonl in .autoskillit/temp/ (project-local, fully cross-platform). Records only ownership tuples: {session_id, caller_session_id, kitchen_id, dispatch_id, recipe_name, step_name, timestamp}. The guard hook reads this project-local file. sessions.jsonl continues as the full diagnostic index.

Rationale for separating from sessions.jsonl:

  • Conceptual clarity: Provenance is core infrastructure (ownership enforcement, resume gating), not observability. Mixing it into a diagnostics log blurs that boundary and risks it being treated as optional or cleanable.
  • Hook simplicity: Guards are stdlib-only. Reading from .autoskillit/temp/ (relative to cwd) is straightforward. Reading from ~/.local/share/autoskillit/logs/ requires reimplementing resolve_log_dir() XDG resolution logic in stdlib-only Python inside a hook script.
  • Project-local scope: A guard validating resume ownership only needs provenance for the current project's sessions, not a global index across all projects.
  • Drift risk is manageable: The provenance write can be co-located with the dispatch code in a single call site — both the diagnostic log and provenance file are written in the same code path (flush_session_log() or alongside it), so they stay in sync by construction.

The caller_session_id should still be added to sessions.jsonl and summary.json as well (Part 1) for diagnostic completeness — the provenance file is the guard's authoritative source, while sessions.jsonl carries the field for observability.

Part 3: PreToolUse Resume Ownership Guard

A new guard hook (or extension of existing fleet_dispatch_guard.py) that fires on dispatch_food_truck when resume_session_id is present.

Validation rules:

  1. Layer check: Look up resume_session_id in the provenance store. It must have a non-empty recipe_name (proving it's an L2 food truck session, not an L3 caller session). Deny if the session is not an L2.
  2. Ownership check: The L2's caller_session_id must match the current caller's data["session_id"] — OR — the L2's kitchen_id must match the current kitchen's ID (to allow a new L3 session in the same kitchen to resume L2s from a prior L3 in that kitchen). Deny if neither matches.
  3. Fail-open: If the provenance store is unreadable or the session ID isn't found (e.g., old sessions before this feature), allow the call with a warning advisory.

Hook infrastructure notes:

  • Guards are stdlib-only (no autoskillit imports) — the guard reads the provenance file directly with stdlib JSON/pathlib
  • Guards receive data["session_id"] (caller's Claude Code session UUID) and data["tool_input"]["resume_session_id"]
  • Existing fleet_dispatch_guard.py (hook_registry.py:107-109) already matches dispatch_food_truck — the new guard can be a separate script added to the same HookDef scripts list
  • Exit 0 always; deny via permissionDecision: "deny" in stdout JSON

Part 4: Naming Clarification

The l3_session_id field in DispatchRecord should be renamed to food_truck_session_id or dispatched_session_id to eliminate the L2/L3 naming confusion that contributed to the original #2010 incident. The current name reflects an internal perspective ("third Claude in the chain") that doesn't match the user-facing L2/L3 terminology.

Affected Components

  • src/autoskillit/execution/session_log.py — add caller_session_id to flush_session_log() and index entry; co-write provenance file
  • src/autoskillit/fleet/state.py — add caller_session_id to DispatchRecord; consider renaming l3_session_id
  • src/autoskillit/fleet/_api.py — thread caller session ID through _run_dispatch()
  • src/autoskillit/server/tools/tools_execution.py — capture caller session ID from MCP request context
  • src/autoskillit/hooks/guards/ — new or extended guard for resume ownership validation
  • src/autoskillit/hook_registry.py — register the new guard script
  • .autoskillit/temp/session_provenance.jsonl — new provenance store

Relationship to Other Issues

Originating Incident

Dispatch for issue #2010. The user passed resume_session_id: "0d07e5d0" (the L3 caller's own session) instead of the L2 food truck session ID. The system had no way to detect or prevent this because session ownership is not tracked. Investigation report: .autoskillit/temp/investigate/investigation_two_layer_dispatch_failure_2010_2026-05-06_133000.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    recipe:implementationRoute: proceed directly to implementationstagedImplementation staged and waiting for promotion to main

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions