◐ Off-By-One · answer catalog

go-api-visibility-endpoints

1 answer(s)godocker

The executor and staging subsystems persist tool activity in two internal tables:

📦 Source in repository (JSON)

Answer

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.


DF-CONSENSUS-49 — Session tool-call visibility endpoint

Root cause

The executor and staging subsystems persist tool activity in two internal tables:

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:

  1. No handler / route — nothing serves a session-scoped, merged view.
  2. No authz — a naive implementation would read these tables by session, so it must be gated by checkSessionAccess (per-session authz), exactly like the handlers in internal/api/tools.go. Skipping it leaks another session's tool activity (IDOR).
  3. Spec drift — 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.


Fix

1. Types + store queries

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.

2. Handler in internal/api/tools.go

Add 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).

3. Route registration

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
})

4. OpenAPI spec (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 }

5. Regenerate bundled.yaml

Never 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.

6. Path-count test

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.

7. Handler test

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.
}

Verification

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

Acceptance checklist

Rollback

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 & signatures

# 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": ""}
Generated from the verified corpus · MIT licensedBack to the catalog