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:
- Session ownership provenance: When an L2 is launched from an L3, record which L3 called it
- Resume ownership enforcement: Only the owning L3 (or a successor with the same
kitchen_id) can resume an L2
- PreToolUse guard: Block
dispatch_food_truck from accepting resume_session_id values that aren't L2 food truck sessions owned by the caller
- 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:
- Thread
caller_session_id through dispatch_food_truck → execute_dispatch → _run_dispatch → executor → flush_session_log()
- Add
caller_session_id field to sessions.jsonl index entry (session_log.py:417-461)
- Add
caller_session_id to DispatchRecord in fleet/state.py
- 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:
- 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.
- 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.
- 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.
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_idtodispatch_food_truck, including its own L3 session ID (which is what caused the #2010 failure, see #2019).The system needs:
kitchen_id) can resume an L2dispatch_food_truckfrom acceptingresume_session_idvalues that aren't L2 food truck sessions owned by the callerCurrent State — What Exists Today
Provenance Fields in
sessions.jsonlsessions.jsonl(execution/session_log.py:417-461) records per-session:session_idkitchen_idcampaign_idkitchen_idfor fleet dispatches; empty for recipe stepsdispatch_id_run_dispatchorder_idrecipe_namestep_namecaller_session_iddispatch_food_truck— never recordedFleet
DispatchRecord(fleet/state.py:50)l3_session_idl3_pidl3_starttime_ticksl3_boot_idPlatform Reality
sessions.jsonlis fully cross-platform — written on both Linux and macOS (session_log.py:64-72handles both). Onlyproc_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_idin Session ProvenanceWhere the caller's session ID is available:
data["session_id"]is the Claude Code session UUID of the callerdispatch_food_trucktool: the MCP server can access the caller session from the request context_run_dispatch(fleet/_api.py): thetool_ctxhaskitchen_idbut not the caller's Claude session IDChanges needed:
caller_session_idthroughdispatch_food_truck→execute_dispatch→_run_dispatch→ executor →flush_session_log()caller_session_idfield tosessions.jsonlindex entry (session_log.py:417-461)caller_session_idtoDispatchRecordinfleet/state.pycaller_session_idtosummary.jsonper-session recordPart 2: Formalize Session Provenance Store
Suggested approach — dedicated provenance index: Create a new
session_provenance.jsonlin.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.jsonlcontinues as the full diagnostic index.Rationale for separating from
sessions.jsonl:.autoskillit/temp/(relative to cwd) is straightforward. Reading from~/.local/share/autoskillit/logs/requires reimplementingresolve_log_dir()XDG resolution logic in stdlib-only Python inside a hook script.flush_session_log()or alongside it), so they stay in sync by construction.The
caller_session_idshould still be added tosessions.jsonlandsummary.jsonas 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 ondispatch_food_truckwhenresume_session_idis present.Validation rules:
resume_session_idin the provenance store. It must have a non-emptyrecipe_name(proving it's an L2 food truck session, not an L3 caller session). Deny if the session is not an L2.caller_session_idmust match the current caller'sdata["session_id"]— OR — the L2'skitchen_idmust 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.Hook infrastructure notes:
data["session_id"](caller's Claude Code session UUID) anddata["tool_input"]["resume_session_id"]fleet_dispatch_guard.py(hook_registry.py:107-109) already matchesdispatch_food_truck— the new guard can be a separate script added to the sameHookDefscripts listpermissionDecision: "deny"in stdout JSONPart 4: Naming Clarification
The
l3_session_idfield inDispatchRecordshould be renamed tofood_truck_session_idordispatched_session_idto 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— addcaller_session_idtoflush_session_log()and index entry; co-write provenance filesrc/autoskillit/fleet/state.py— addcaller_session_idtoDispatchRecord; consider renamingl3_session_idsrc/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 contextsrc/autoskillit/hooks/guards/— new or extended guard for resume ownership validationsrc/autoskillit/hook_registry.py— register the new guard script.autoskillit/temp/session_provenance.jsonl— new provenance storeRelationship 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.