The executor and staging subsystems persist tool activity in two internal tables:
No repository is mounted in this environment (/workspace only contains problem.json), so I can't patch live files. Below is the complete, self-contained solution against the file layout and conventions named in the problem class (internal/api/tools.go, checkSessionAccess, specs/openapi, bundled.yaml, path-count tests). It is written as a ready-to-apply patch + verification recipe.
The executor and staging subsystems persist tool activity in two internal tables:
tool_requests — executor-owned rows (id, session_id, tool_name, arguments, status, result, error, tool_call_ref, created_at, updated_at).staging_buffer — pre-dispatch rows (id, session_id, tool_call_ref, tool_name, arguments, status, created_at).Neither table is reachable through the HTTP API, so a client cannot reconcile what it asked for against what the executor queued/ran, nor see a tool call that is still staged. The change that exposes them was only partially landed, causing three coupled failures:
checkSessionAccess (per-session authz), exactly like the handlers in internal/api/tools.go. Skipping it leaks another session's tool activity (IDOR).specs/openapi and the generated bundled.yaml are not updated, and the path-count test still asserts the old route count, so go test ./... (or the spec check) fails. All three must land in the same commit.The correct fix is a single merged endpoint that unions executor rows and staging rows, stamps each with a status field and a source, de-duplicates staged rows already promoted into tool_requests, and is authorized per session.
internal/store/tool_calls.go (new)
package store
import (
"context"
"database/sql"
"encoding/json"
"time"
)
// ToolCallStatus is the normalized status surfaced to clients.
type ToolCallStatus string
const (
ToolCallStatusStaged ToolCallStatus = "staged"
ToolCallStatusQueued ToolCallStatus = "queued"
ToolCallStatusRunning ToolCallStatus = "running"
ToolCallStatusCompleted ToolCallStatus = "completed"
ToolCallStatusFailed ToolCallStatus = "failed"
)
// ToolRequest mirrors a row in the internal executor table.
type ToolRequest struct {
ID string
SessionID string
ToolName string
Arguments json.RawMessage
Status string
Result json.RawMessage
Error string
ToolCallRef string
CreatedAt time.Time
UpdatedAt time.Time
}
// StagedToolCall mirrors a row in the staging buffer.
type StagedToolCall struct {
ID string
SessionID string
ToolCallRef string
ToolName string
Arguments json.RawMessage
Status string
CreatedAt time.Time
}
func (s *Store) ListToolRequestsBySession(ctx context.Context, sessionID string) ([]ToolRequest, error) {
const q = `
SELECT id, session_id, tool_name, arguments, status,
result, error, tool_call_ref, created_at, updated_at
FROM tool_requests
WHERE session_id = $1
ORDER BY created_at ASC`
rows, err := s.db.QueryContext(ctx, q, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []ToolRequest
for rows.Next() {
var tr ToolRequest
if err := rows.Scan(
&tr.ID, &tr.SessionID, &tr.ToolName, &tr.Arguments, &tr.Status,
&tr.Result, &tr.Error, &tr.ToolCallRef, &tr.CreatedAt, &tr.UpdatedAt,
); err != nil {
return nil, err
}
out = append(out, tr)
}
return out, rows.Err()
}
func (s *Store) ListStagedToolCallsBySession(ctx context.Context, sessionID string) ([]StagedToolCall, error) {
const q = `
SELECT id, session_id, tool_call_ref, tool_name, arguments, status, created_at
FROM staging_buffer
WHERE session_id = $1
ORDER BY created_at ASC`
rows, err := s.db.QueryContext(ctx, q, sessionID)
if err != nil {
return nil, err
}
defer rows.Close()
var out []StagedToolCall
for rows.Next() {
var sc StagedToolCall
var ref sql.NullString
if err := rows.Scan(&sc.ID, &sc.SessionID, &ref, &sc.ToolName,
&sc.Arguments, &sc.Status, &sc.CreatedAt); err != nil {
return nil, err
}
sc.ToolCallRef = ref.String
out = append(out, sc)
}
return out, rows.Err()
}
Adapt the receiver/DB handle names to match
internal/store. If the project already has partial queries, reuse them rather than duplicating.
internal/api/tools.goAdd the DTO and handler next to the existing tool handlers. Reuse the package's existing writeJSON / error helpers and the checkSessionAccess helper.
// ToolCallView is the merged, client-facing representation of a tool call.
type ToolCallView struct {
ID string `json:"id"`
SessionID string `json:"session_id"`
ToolName string `json:"tool_name"`
Status string `json:"status"`
Source string `json:"source"` // "executor" | "staging"
ToolCallRef *string `json:"tool_call_ref,omitempty"`
Arguments json.RawMessage `json:"arguments,omitempty"`
Result json.RawMessage `json:"result,omitempty"`
Error string `json:"error,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// handleListSessionToolCalls serves GET /api/v1/sessions/{sessionID}/tool-calls.
// It returns a merged, session-scoped view of executor rows (tool_requests) and
// staged rows (staging_buffer). Access is enforced per session.
func (s *Server) handleListSessionToolCalls(w http.ResponseWriter, r *http.Request) {
sessionID := chi.URLParam(r, "sessionID")
if sessionID == "" {
writeError(w, http.StatusBadRequest, "missing session id", nil)
return
}
// Per-session authz — same pattern used by every other handler in this file.
if _, err := s.checkSessionAccess(r.Context(), sessionID); err != nil {
writeError(w, http.StatusForbidden, "session access denied", err)
return
}
ctx := r.Context()
requests, err := s.store.ListToolRequestsBySession(ctx, sessionID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list tool requests", err)
return
}
staged, err := s.store.ListStagedToolCallsBySession(ctx, sessionID)
if err != nil {
writeError(w, http.StatusInternalServerError, "list staged tool calls", err)
return
}
// Index executor rows by ref so a staged row that was already promoted is
// not returned twice. Executor status wins; staging only adds rows whose
// ref has not yet reached the executor.
promoted := make(map[string]struct{}, len(requests))
for _, tr := range requests {
if tr.ToolCallRef != "" {
promoted[tr.ToolCallRef] = struct{}{}
}
}
views := make([]ToolCallView, 0, len(requests)+len(staged))
for _, tr := range requests {
v := ToolCallView{
ID: tr.ID,
SessionID: tr.SessionID,
ToolName: tr.ToolName,
Status: normalizeToolStatus(tr.Status),
Source: "executor",
Arguments: tr.Arguments,
Result: tr.Result,
Error: tr.Error,
CreatedAt: tr.CreatedAt,
UpdatedAt: tr.UpdatedAt,
}
if tr.ToolCallRef != "" {
ref := tr.ToolCallRef
v.ToolCallRef = &ref
}
views = append(views, v)
}
for _, sc := range staged {
if sc.ToolCallRef != "" {
if _, ok := promoted[sc.ToolCallRef]; ok {
continue // already represented by an executor row
}
}
v := ToolCallView{
ID: sc.ID,
SessionID: sc.SessionID,
ToolName: sc.ToolName,
Status: normalizeToolStatus(sc.Status),
Source: "staging",
Arguments: sc.Arguments,
CreatedAt: sc.CreatedAt,
UpdatedAt: sc.CreatedAt,
}
if sc.ToolCallRef != "" {
ref := sc.ToolCallRef
v.ToolCallRef = &ref
}
views = append(views, v)
}
sort.SliceStable(views, func(i, j int) bool {
return views[i].CreatedAt.Before(views[j].CreatedAt)
})
writeJSON(w, http.StatusOK, map[string]any{"tool_calls": views})
}
// normalizeToolStatus maps internal/executor state strings to the public set.
func normalizeToolStatus(s string) string {
switch strings.ToLower(strings.TrimSpace(s)) {
case "staged":
return string(ToolCallStatusStaged)
case "pending", "queued":
return string(ToolCallStatusQueued)
case "running", "in_progress":
return string(ToolCallStatusRunning)
case "completed", "succeeded", "success", "done":
return string(ToolCallStatusCompleted)
case "failed", "error":
return string(ToolCallStatusFailed)
default:
if s == "" {
return string(ToolCallStatusStaged)
}
return strings.ToLower(s)
}
}
Add imports as needed: encoding/json, net/http, sort, strings, time, github.com/go-chi/chi/v5. Reference the store status constants (or keep the local switch if internal/api already imports the store package under a different alias).
In the router where the existing session subroutes are mounted (internal/api/router.go / server.go):
r.Route("/sessions/{sessionID}", func(r chi.Router) {
r.Get("/tool-calls", s.handleListSessionToolCalls)
// ... existing routes unchanged
})
specs/openapi)Append the path (bump nothing else structurally):
/api/v1/sessions/{sessionID}/tool-calls:
get:
summary: List merged tool calls for a session
description: >
Returns executor tool requests (tool_requests) merged with staged tool
calls (staging_buffer). Staged rows already promoted to the executor are
omitted. Access is restricted to the caller's session.
operationId: listSessionToolCalls
tags: [tools]
parameters:
- name: sessionID
in: path
required: true
schema: { type: string }
responses:
"200":
description: Merged list of tool calls
content:
application/json:
schema:
type: object
required: [tool_calls]
properties:
tool_calls:
type: array
items:
$ref: "#/components/schemas/ToolCallView"
"403":
description: Caller cannot access this session
"404":
description: Session not found
Add the component schema:
ToolCallView:
type: object
required: [id, session_id, tool_name, status, source, created_at, updated_at]
properties:
id: { type: string }
session_id: { type: string }
tool_name: { type: string }
status:
type: string
enum: [staged, queued, running, completed, failed]
source:
type: string
enum: [executor, staging]
tool_call_ref: { type: string, nullable: true }
arguments: { type: object, additionalProperties: true }
result: { type: object, additionalProperties: true }
error: { type: string }
created_at: { type: string, format: date-time }
updated_at: { type: string, format: date-time }
bundled.yamlNever hand-edit the bundle. Regenerate it from the source spec in the same commit:
make bundle-openapi # or: go generate ./specs/... / ./scripts/bundle-openapi.sh
git diff -- specs/openapi/bundled.yaml
The bundled file must contain the new /api/v1/sessions/{sessionID}/tool-calls path and ToolCallView schema.
Locate the spec guard test that counts registered paths (typically specs/openapi_test.go or internal/api/openapi_test.go, e.g. TestOpenAPIPathsMatchRouter / wantPaths). Bump the expected count by exactly 1 and add the path, following how the existing session routes are listed:
func TestSpecPathCount(t *testing.T) {
// was 41; +1 for GET /api/v1/sessions/{sessionID}/tool-calls
const want = 42
if got := len(spec.Paths.Map()); got != want {
t.Fatalf("openapi path count = %d, want %d", got, want)
}
}
If the test uses a golden list, add "/api/v1/sessions/{sessionID}/tool-calls" to it instead of (or as well as) bumping the number.
internal/api/tools_test.go (new test cases)
func TestListSessionToolCalls_MergesAndDedupes(t *testing.T) {
// session s1: one executor row with ref "ref-a" + one staged row ref "ref-a"
// (must be dropped) + one staged row ref "ref-b" (must appear).
// session s2: rows that must never appear for the s1 caller.
// Assert: 200, 2 items, sources {executor, staging}, statuses normalized.
}
func TestListSessionToolCalls_ForbiddenForOtherSession(t *testing.T) {
// caller authorized for s1, request s2 -> 403, and store must not be hit.
}
Run from the repo root after applying the patch:
# 1. Compile
go build ./...
# 2. Targeted handler + authz tests
go test ./internal/api/... ./internal/store/... -run 'ToolCall|SpecPath' -count=1 -v
# 3. Full suite (spec guard, path-count, bundling drift)
go test ./... -count=1
# 4. Bundle is in sync — must print no diff
make bundle-openapi
git diff --exit-code -- specs/openapi/bundled.yaml
# 5. OpenAPI validity
npx @redocly/cli lint specs/openapi/bundled.yaml # or: make lint-openapi
Manual smoke test (expected: executor rows + un-promoted staging rows, sorted by created_at):
curl -sS -H "Authorization: Bearer $TOKEN" \
"$BASE/api/v1/sessions/$SESSION_ID/tool-calls" | jq .
Expected shape:
{
"tool_calls": [
{
"id": "tr_1",
"session_id": "s1",
"tool_name": "read_file",
"status": "completed",
"source": "executor",
"tool_call_ref": "ref-a",
"arguments": {"path": "a.txt"},
"created_at": "2026-01-01T00:00:00Z",
"updated_at": "2026-01-01T00:00:01Z"
},
{
"id": "sb_2",
"session_id": "s1",
"tool_name": "write_file",
"status": "staged",
"source": "staging",
"tool_call_ref": "ref-b",
"arguments": {"path": "b.txt"},
"created_at": "2026-01-01T00:00:02Z",
"updated_at": "2026-01-01T00:00:02Z"
}
]
}
Authz negative check — must be 403 (and must not leak s2 rows):
curl -sS -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $TOKEN_FOR_S1" \
"$BASE/api/v1/sessions/s2/tool-calls"
# => 403
GET /api/v1/sessions/{sessionID}/tool-calls returns executor + staging rows merged with status and source.tool_call_ref already exists in tool_requests are de-duplicated (executor wins).checkSessionAccess before touching the store; cross-session access returns 403.specs/openapi and regenerated bundled.yaml both contain the path and ToolCallView.go test ./... is green.Revert the single commit; no migrations are introduced (the endpoint is read-only over existing tables), so rollback is limited to removing the route/handler/spec and restoring the previous path count.
# Evidence - Problem class: go-api-visibility-endpoints - Model: openrouter/deepseek/deepseek-v4.1-flash - Solved: 2026-10-10T19:38:16.817Z - Verification: solution produced by pi in sandbox; see signatures.json
{"description": "DF-CONSENSUS-49: exposing executor/internal tables (tool_requests, staging_buffer tool_call_ref) to clients. Pattern: follow existing handler conventions in internal/api/tools.go + checkSessionAccess for per-session authz; merged view endpoint combines executor rows and staging rows with a status field; update specs/openapi + bundled.yaml and path-count tests in the same commit.", "environment": "", "language": "", "model": "openrouter/deepseek/deepseek-v4.1-flash", "problem_class": "go-api-visibility-endpoints", "provider": "openrouter", "solved_at": "2026-10-10T19:38:16.818Z", "version": ""}