#2065

ENG-661: Add the ai-handoff risk level action

Draftracerxdlwants to merge intofrom

IMPORTANT

Every PR MUST have exactly one of these labels, the merge is blocked otherwise:

  • major / minor / patch publishes a new release on merge with the corresponding semver bump.
  • skip-release merges without publishing a release (use for docs, CI changes, refactors, etc.).

📝 Description

Adds the ai-handoff risk level action. After classification, a second LLM turn picks one action from the level's allowed_actions, and records it with a reason. Stacked on #2053 (ENG-680).

📣 User-facing impact

A risk level can let an AI agent choose allow, warn, block or hold for approval, limited to the actions the admin allows.

🔗 Related Issue

ENG-661

🚀 Type of Change

  • 🐛 Bug fix (non-breaking change which fixes an issue)
  • ✨ New feature (non-breaking change which adds functionality)
  • 💥 Breaking change (fix or feature that would cause existing functionality to not work as expected)
  • 📚 Documentation update
  • 🎨 Style/UI update
  • ♻️ Code refactor
  • ⚡ Performance improvement
  • ✅ Test update
  • 🔧 Build configuration change
  • 🧹 Chore

📋 Changes Made

  • sidecar: allowed_actions (list or object form) on a risk level, the hand-off turn (analyzer/handoff.go) with one tool per allowed action, the fixed suffix and the fail-safe to the most restrictive allowed action. Capability analyzer_ai_handoff, the warnings from the ticket table, and README docs.
  • gateway: handoff: true filings are authorized against the level's allowed_actions.require_approval, and reviews store via_handoff (migration 000148). Each statement's AI decisions go to the session metadata (metadata.sidecar.ai_decisions), bounded in count, size and vocabulary.
  • webapp_v2: "Hand off to AI agent" action, with a Hand-off card holding the prompt and the allowed actions, and the Approver card under "Hold for approval". The approvals page shows "Hand-off → Hold for approval".
  • webapp: an "AI decisions" section on the session details page.

🧪 Testing

Result: a risk level can now let an AI agent pick allow, warn, block or hold for approval for each statement, and the audit records the pick and its reason. When the agent does not decide, the most restrictive allowed action applies. Tested on 5c7e673b against its base eng-680 1b04dfdd.

1. Does the agent pick the action, and does it fail safe?

A real sidecar in front of Postgres, with QA's stub LLM. Each # Step line says what the stub answers next. What to look at:

  • Step 2: eng-680 refuses the config (unknown field "allowed_actions"), so nothing starts. eng-661 loads both lanes.
  • Steps 3–6, right: allow and warn give UPDATE 1. Block shows the agent's reason to the psql user. Hold for approval goes to the approval agent, which approves.
  • Steps 7–8: a refusal blocks ("the hand-off agent did not decide"). Lane reports allows only allow and warn: the agent calls block, which it may not, so warn applies.
  • Last frame: Step 3's statement again is a cache hit (LLM calls: 0) that keeps its reason. The audit has handoff_decision and handoff_reason for each statement.

2. Does -validate warn for the ticket's table, and is an old sidecar refused?

One config per row of the warning table. eng-680 refuses all six. eng-661 loads each one and prints a note: that names the ignored setting. DURING DEPLOY: the eng-661 plane refuses to serve each config to an eng-680 sidecar build and names the capability analyzer_ai_handoff (daemon.DowngradeRiskLevels, which the plane calls at serve time).

3. Can an admin set up a hand-off in the UI?

Same seed (groups dba, sre) and the same rule name. What to look at:

  • The open High risk action list: eng-661 ends with "Hand off to AI agent". eng-680 ends at "Hold for approval", so the left side can only hold for a person.
  • Middle, right: the Hand-off card takes a prompt and the allowed actions (Allow unchecked). The Approver card under it sets Human, dba, for the hold.
  • Last frame, overlay bottom-left (test overlay, read back after Save): eng-661 stores action: ai-handoff, the prompt, and allowed_actions with require_approval → human, dba. The reopened form shows the same values.

What it solves: Admins can let an AI agent triage a risk level: run routine statements, warn, block, or hold the doubtful ones for a person or the approval agent, each with a recorded reason. Before, a level had one fixed action.

What QA caught on the way

QA ran 65 live Postgres scenarios and 7 DB-backed tests: all passed, no product defect. The review found three defects, all fixed:

  • R1 (P1): a hand-off decision was reused for another user, another database or another quoted identifier with the same statement shape. The decision is now keyed on the exact content the agent read. TestAHandoffDecisionIsKeyedOnWhatTheAgentRead, TestAHandoffDecisionIsNotReusedForAnotherIdentifier.
  • R2 (P1): the form read an empty require_approval: {} beside a configured require_review as the AI default, and a save dropped the human gate and its groups. The form now reads the aliases as the sidecar does, and shows two configured aliases read-only and saves them unchanged. No automated test (see Open items); the sidecar rule it mirrors is in TestAllowedActionsDecodeAndEncodeBothForms.
  • R3 (P2): a sidecar token could grow a session's AI decision metadata without bound. Each field now keeps a vocabulary value or a bounded reason, and the list has a byte ceiling. TestPostEventsBoundsTheAIDecisions, TestPlanSidecarSessionAIDecisionVocabulary, TestPlanSidecarSessionCapsAIDecisionBytes.
  • A hand-off filing is authorized against the config served to that build: a build without analyzer_ai_handoff gets 422. TestAnOldBuildIsRefusedAHandoffLevel.

Acceptance criteria

  • ai-handoff makes a second LLM turn after classification, with the same provider and model (live relay, llm-requests.jsonl)
  • The agent receives only the tools for allowed_actions and cannot select ai-handoff (TestAHandoff*, live restricted lanes)
  • allowed_actions accepts the list form and the object form (TestAllowedActionsDecodeAndEncodeBothForms)
  • require_approval from the agent uses allowed_actions.require_approval for both approver types (live human and AI holds; TestAHandoffHoldIsAuthorizedByItsAllowedActions, TestAHandoffAgentDecisionIsFiledDecided)
  • The fixed suffix is always appended, also after a user prompt (56 captured requests)
  • A failed or invalid decision applies the most restrictive allowed action (live matrix: no call, two calls, disallowed tool, missing reason, refusal, timeout, provider error)
  • The hand-off call counts against max_calls and uses the cache (live budgets 1/2/3; TestAHandoffDecisionIsKeyedOnWhatTheAgentRead)
  • -validate warns for every row of the warning table
  • The UI sets ai-handoff with a prompt, allowed actions and an approval configuration (save and read back)
  • Docs: sidecar/README.md
  • Session details show the hand-off decision with live data (licensed QA on b33e3400; needs experimental.sidecar_session_events)
  • UI matches DES-222: Figma has no frame for the hand-off settings; the card follows the Approver card style

Re-run

(cd sidecar && go test ./...) && (cd sidecar/analyzer/vertex && CGO_ENABLED=0 go test ./...)
(cd gateway && go test -timeout 50m ./models/ ./api/sidecar/ ./services/... ./api/session/ ./api/review/)
(cd sidecar/cmd && go run . -validate -config <file with an ai-handoff level>)
(cd webapp_v2 && npm run lint && npm run build)

Test Configuration:

  • Browser(s): Chromium (rec.mjs)
  • OS: macOS (arm64)

Tests performed:

  • Unit tests pass
  • Integration tests pass (CI pending)
  • Manual testing completed (sidecar + Postgres + stub LLM; UI on isolated gateways)

📸 Screenshots (if applicable)

See Testing.

✅ Checklist

  • My code follows the style guidelines of this project
  • I have performed a self-review of my own code
  • I have commented my code, particularly in hard-to-understand areas
  • My changes generate no new warnings
  • New and existing unit tests pass locally with my changes
  • I have checked my code and corrected any misspellings

📄 Additional Notes

Risk: medium.

  • Stacked on #2053: merge that first.
  • Sidecar↔plane: a hand-off level needs capability analyzer_ai_handoff. The plane refuses to serve it to an older build (no downgrade). No gateway↔agent change.
  • Migration 000148 adds reviews.via_handoff. Its down expires a live hand-off review that would clash with a direct one.
  • The hand-off decision is cached on the exact content the agent read, so literal variants make a new call. The classification cache stays shape-keyed.

Open items

  • Exercised live with the Enterprise license on b33e3400 (licensed plane, managed sidecar, Postgres; QA report b33e3400-qa-report.json, PASS): a human hold under a hand-off filed a review, a non-member got 404, a member approved and the retry ran; the approval shows "Hand-off → Hold for approval"; the session page shows the hand-off decision and reason. Not recorded as video here.
  • The session "AI decisions" section needs experimental.sidecar_session_events. With it off, the session page shows no AI decisions.
  • Figma has no frame for the hand-off settings. The Hand-off card follows the Approver card style.
  • R2 has no automated test: webapp_v2 has no unit-test runner, and no Go test loads an empty alias beside a configured one.
  • The functional QA round ran on 3cc11b80; the recordings are on 5c7e673b, after the Hand-off card took the design's card style.
  • The agent ran against QA's stub LLM, not a real provider. The UI ran on Vite against isolated gateways (PGlite), not the packaged webapp.
  • The old-build refusal is shown through DowngradeRiskLevels, not by an eng-680 sidecar connected to a live plane.
  • External docs are not published yet.

Activity

30
github-actions[bot]4d ago

Migration Safety Analysis

Summary

MEDIUM – Rollback is destructive and irreversible; data loss risk during down migration.

Files Analyzed

  • gateway/migrations/000148_review_via_handoff.up.sql
  • gateway/migrations/000148_review_via_handoff.down.sql

Findings

Rollback Safety

  • Pair exists: Both .up.sql and .down.sql files present
  • Down migration is safe: The .down.sql performs a destructive ALTER TABLE reviews DROP COLUMN IF EXISTS via_handoff, which permanently removes the column and all its data. This cannot be recovered.
  • Down migration is reversible: Once via_handoff is dropped, there is no way to restore the boolean values that were stored in that column during the up migration.

Destructive Operations

  • DROP COLUMN in down migration: ALTER TABLE reviews DROP COLUMN IF EXISTS via_handoff will delete all data in the via_handoff column irreversibly.
  • Data mutation in down migration: The down migration performs an UPDATE on the reviews table, expiring certain hand-off reviews and marking sessions as 'done'. This modifies live data and cannot be undone by re-running the up migration.

Down Migration Quality

  • Mismatch between up and down:
    • Up adds the via_handoff column with DEFAULT false and recreates the unique index to include via_handoff.
    • Down attempts to reverse the index change but then drops the column entirely.
    • The down migration also performs business logic (expiring reviews, updating session status) that is not idempotent—running the up migration again will not restore the expired reviews or reset session statuses.
    • This is a one-way data loss, not a true rollback.

Sandbox Deploy Risk

  • HIGH RISK for iterative development:
    • If this migration is applied to a sandbox DB and then you need to roll back the gateway binary to a previous version, running the down migration will permanently delete the via_handoff column and modify review/session state.
    • Re-deploying the newer binary afterward will re-add the column, but all historical via_handoff values will be lost (reset to DEFAULT false).
    • Any reviews that were manually expired or sessions marked 'done' will not be restored.

Best Practices

  • Missing reversibility guarantee: The down migration contains business logic (review expiration, session status updates) that should not be in a migration rollback. This logic should either:
    • Be idempotent (safe to run multiple times with no ill effects), or
    • Be removed from the down migration entirely, accepting that rollback is not fully supported.
  • IF EXISTS guards: Present on DROP INDEX IF EXISTS and ALTER TABLE ... DROP COLUMN IF EXISTS, which is good defensive practice, but does not solve the data loss problem.
  • No transaction safety issue: Wrapped in BEGIN; ... COMMIT;, so atomicity is guaranteed.

Recommendations

  1. Clarify rollback expectations: Decide whether this migration is intended to be truly reversible:

    • If yes: Remove all business logic (review expiration, session updates) from the down migration. The down migration should only be ALTER TABLE reviews DROP COLUMN IF EXISTS via_handoff; or not exist at all, acknowledging that this is a forward-only change in production.
    • If no: Document this migration as forward-only and ensure team processes prevent sandbox rollbacks after this migration is deployed.
  2. Consider a soft-delete approach: Instead of dropping the column, consider renaming it or marking it as deprecated if rollback support is needed in the future.

  3. Audit the business logic in down migration: Verify that expiring reviews and updating session status should happen during a rollback scenario. If these are critical state changes, they should be handled outside of migrations.

  4. For sandbox safety: If iterative rollback is required, either:

    • Remove this migration from the deployment and revisit the design, or
    • Treat this as a forward-only migration and document the operational constraint.

Automated analysis by Migration Safety Check. Review carefully -- LLM analysis may miss edge cases.

github-actions[bot]4d ago

📋 API Changelog

API Changelog unknown vs. unknown

API Changes

GET /approvals

  • added the optional property /items/via_handoff to the response with the 200 status

GET /approvals/{id}

  • added the optional property via_handoff to the response with the 200 status

PUT /approvals/{id}

  • added the optional property via_handoff to the response with the 200 status

GET /reviews

  • added the optional property /items/via_handoff to the response with the 200 status

GET /reviews/{id}

  • added the optional property via_handoff to the response with the 200 status

PUT /reviews/{id}

  • added the optional property via_handoff to the response with the 200 status

GET /sessions

  • added the optional property data/items/review/allOf[#/components/schemas/openapi.SessionReview]/via_handoff to the response with the 200 status

GET /sessions/{session_id}

  • added the optional property review/allOf[#/components/schemas/openapi.SessionReview]/via_handoff to the response with the 200 status

PUT /sessions/{session_id}/approval

  • added the optional property via_handoff to the response with the 200 status

PUT /sessions/{session_id}/review

  • added the optional property via_handoff to the response with the 200 status

GET /sidecars/reviews

  • added the optional property /items/via_handoff to the response with the 200 status

POST /sidecars/reviews

  • added the new optional request property handoff
  • added the optional property review/allOf[#/components/schemas/openapi.Review]/via_handoff to the response with the 200 status
  • added the optional property review/allOf[#/components/schemas/openapi.Review]/via_handoff to the response with the 201 status

GET /sidecars/reviews/{id}

  • added the optional property via_handoff to the response with the 200 status

POST /sidecars/reviews/{id}/claim

  • added the optional property review/allOf[#/components/schemas/openapi.Review]/via_handoff to the response with the 200 status

POST /sidecars/reviews/{id}/settle

  • added the optional property review/allOf[#/components/schemas/openapi.Review]/via_handoff to the response with the 200 status
racerxdl4d ago

/agentic_review

qodo-code-review[bot]4d ago

Code Review by Qodo

🐞 Bugs (0) 📘 Rule violations (1) 📎 Requirement gaps (0) 🔗 Cross-repo conflicts (0) 📜 Skill insights (0)


1. The AI decisions panel uses Tailwind styles ✗ Dismissed
Description
The new session analysis view assigns Tailwind utility classes such as w-full, p-3,
bg-[--gray-1], and focus:outline-none to the AI decisions accordion and its controls. Rendering
this panel reaches the new decision list, trigger, badges, links, and focus styling, introducing
prohibited Tailwind class usage across the changed UI.
Code

webapp/src/webapp/features/ai_session_analyzer/views/session_analysis.cljs[R82-88]

+    [:> (.-Root Accordion) {:type "single"
+                            :collapsible true
+                            :class "w-full p-3 bg-[--gray-1] rounded-md border border-[--gray-3]"}
+     [:> (.-Item Accordion) {:value "ai-decisions"
+                             :className "border-none"}
+      [:> (.-Header Accordion)
+       [:> (.-Trigger Accordion) {:className "group flex w-full items-center justify-between gap-3 text-left focus:outline-none focus-visible:ring focus-visible:ring-gray-500 focus-visible:ring-opacity-75"}
Relevance

●●● Strong

Changed UI directly violates the stated styling rule; similar CLJS styling cleanup was accepted.

PR-#1429

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The changed Reagent session analysis view adds multiple Tailwind utility class strings to the new AI
decisions accordion and its contents, including w-full, p-3, flex, focus:outline-none, and
text-gray-10.

Rule 1800957: Disallow Tailwind CSS class usage in React components (Mantine-only styling) webapp/src/webapp/features/ai_session_analyzer/views/session_analysis.cljs[82-109]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The new AI decisions view uses Tailwind utility class strings in Reagent UI code, including layout, spacing, colors, and focus styles, while the project requires Mantine-only styling.

## Fix Focus Areas
- webapp/src/webapp/features/ai_session_analyzer/views/session_analysis.cljs[82-88]
- webapp/src/webapp/features/ai_session_analyzer/views/session_analysis.cljs[90-109]

## Recommended Fix
Replace the Tailwind utility classes with the project's approved Mantine component props, existing component styles, or scoped CSS module classes. Preserve the accordion layout, visible focus indicator, spacing, colors, and text wrapping without retaining Tailwind utility names.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Migration tests bypass shared DB setup ✗ Dismissed
Description
TestReviewViaHandoffMigrationRoundTrip starts pglite directly and manually migrates it instead
of using the shared migrated-database helper. The test still runs against a resumed database state,
so its independent lifecycle can diverge from the package-wide test setup and migration behavior.
Code

gateway/models/review_via_handoff_migration_test.go[R25-27]

+	inst, err := pglite.Start(ctx, t.TempDir())
+	require.NoError(t, err)
+	t.Cleanup(func() { inst.Close(ctx) })
Relevance

●●● Strong

Explicit helper rule and deterministic shared-lifecycle fix; migration tests should use the package
helper.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The new migration test directly calls pglite.Start and performs manual migration setup, while the
checklist requires migrated embedded database tests to use pglitetest.StartMigrated(t) and the
package-level shared lifecycle.

Rule 3854809: Use pglitetest helpers for migrated embedded database tests gateway/models/review_via_handoff_migration_test.go[25-36]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`TestReviewViaHandoffMigrationRoundTrip` directly starts and closes an embedded database, bypassing the required `pglitetest` lifecycle for migrated database tests.

## Fix Focus Areas
- gateway/models/review_via_handoff_migration_test.go[25-36]

## Recommended Fix
Refactor the test setup to use the shared `pglitetest` package and its package-level `TestMain` lifecycle. Preserve the test's need to migrate to historical versions by using a supported helper or extending the shared test helper rather than constructing and migrating the embedded database directly in the test.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Resumed tests leave a catalog view unqualified ✗ Dismissed
Description
TestReviewViaHandoffMigrationRoundTrip queries pg_indexes without a schema prefix while checking
the migrated review index. The resumed embedded database does not guarantee the migration search
path, so this catalog lookup relies on implicit resolution instead of explicitly targeting
pg_catalog.pg_indexes.
Code

gateway/models/review_via_handoff_migration_test.go[R84-85]

+		require.NoError(t, models.DB.Raw(`SELECT indexdef FROM pg_indexes
+			WHERE schemaname = 'private' AND indexname = 'index_reviews_sidecar_level_statement'`).Row().Scan(&indexDef))
Relevance

●●● Strong

Schema qualification is a deterministic SQL correctness fix explicitly required for resumed
embedded-database tests.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The added SQL query uses FROM pg_indexes without a schema prefix in a test that resumes an
embedded database; the checklist requires all non-default-schema tables and views in such tests to
be schema-qualified.

Rule 3854814: Schema-qualify SQL identifiers in tests using resumed embedded databases gateway/models/review_via_handoff_migration_test.go[84-85]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The resumed-database migration test references the `pg_indexes` view without an explicit schema qualification.

## Fix Focus Areas
- gateway/models/review_via_handoff_migration_test.go[84-85]

## Recommended Fix
Change the query to reference `pg_catalog.pg_indexes` explicitly while retaining the existing `schemaname` and `indexname` filters.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View medium (3)
4. New UI files keep PascalCase names ✗ Dismissed
Description
The new UI modules LevelHandoff.jsx, PromptField.jsx, and SettingsCard.jsx use PascalCase
filenames instead of the required kebab-case convention. Future contributors and tooling that expect
normalized lowercase file names encounter inconsistent paths across the newly added components.
Code

webapp_v2/src/pages/Features/AiSessionAnalyzer/Create/sections/LevelHandoff.jsx[1]

+import { Box, Paper, Stack, Text } from '@mantine/core'
Relevance

●●● Strong

New filenames directly violate the explicit kebab-case convention, making this a straightforward
naming fix.

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The diff adds three JavaScript component files whose names contain uppercase letters and do not
follow kebab-case. The naming checklist requires new JavaScript files and folders to use lowercase
words separated by single hyphens.

Rule 1800939: Use kebab-case only for file and folder names, not for code identifiers webapp_v2/src/pages/Features/AiSessionAnalyzer/Create/sections/LevelHandoff.jsx[1-1] webapp_v2/src/pages/Features/AiSessionAnalyzer/components/PromptField.jsx[1-1] webapp_v2/src/pages/Features/AiSessionAnalyzer/components/SettingsCard.jsx[1-1]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The newly added JavaScript component files use PascalCase filenames, violating the repository naming convention that requires kebab-case file and folder names.

## Fix Focus Areas
- webapp_v2/src/pages/Features/AiSessionAnalyzer/Create/sections/LevelHandoff.jsx[1-81]
- webapp_v2/src/pages/Features/AiSessionAnalyzer/components/PromptField.jsx[1-56]
- webapp_v2/src/pages/Features/AiSessionAnalyzer/components/SettingsCard.jsx[1-45]

## Recommended Fix
Rename the files to `level-handoff.jsx`, `prompt-field.jsx`, and `settings-card.jsx`, then update every import path that references them.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


5. The settings card uses inline styling ✓ Resolved
Description
SettingsCard passes style={{ overflow: 'hidden' }} directly to its Mantine Paper component.
Every use of this shared card renders the instance-local style, making the component violate the
project's restriction on inline style props.
Code

webapp_v2/src/pages/Features/AiSessionAnalyzer/components/SettingsCard.jsx[11]

+    <Paper withBorder radius="lg" style={{ overflow: 'hidden' }}>
Relevance

●●● Strong

Recent webapp precedent accepted replacing inline styling with stylesheet-backed styling.

PR-#1833

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The changed SettingsCard implementation adds an inline style object to a Mantine Paper
component, directly matching the violation described by the checklist rule.

Rule 1800977: Disallow inline style prop usage on React components webapp_v2/src/pages/Features/AiSessionAnalyzer/components/SettingsCard.jsx[11-11]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`SettingsCard` uses an inline `style` object on a Mantine component, which violates the project's rule against inline style props.

## Fix Focus Areas
- webapp_v2/src/pages/Features/AiSessionAnalyzer/components/SettingsCard.jsx[11-11]

## Recommended Fix
Move the overflow rule into a co-located CSS module or use an approved Mantine styling API, then apply the resulting class or supported prop to `Paper` instead of passing `style={{ overflow: 'hidden' }}`.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Session list responses grow by up to 256 KiB per sidecar session ✓ Resolved
Description
planSidecarSession now appends up to 200 entries, capped at 256 KiB, to
metadata.sidecar.ai_decisions for every statement the analyzer classifies, allowed ones included.
The session list query selects s.metadata in full and toOpenApiSession returns it for every row,
so a page of up to 100 sidecar sessions can carry about 25 MiB of decision history the list never
shows. Every event batch also rewrites the enlarged jsonb column.
Code

gateway/services/sidecarsessions.go[R61-72]

+// maxSidecarAIDecisions caps metadata.sidecar.ai_decisions per session;
+// metadata.sidecar.ai_decisions_dropped counts the rest.
+const maxSidecarAIDecisions = 200
+
+// maxSidecarAIDecisionReasonChars bounds a reason an entry stores. The
+// sidecar caps the agents' reasons below it.
+const maxSidecarAIDecisionReasonChars = 4096
+
+// maxSidecarAIDecisionsBytes caps the stored text of
+// metadata.sidecar.ai_decisions; an entry past it is counted in
+// ai_decisions_dropped. The reasons are the only free text an entry holds.
+const maxSidecarAIDecisionsBytes = 256 << 10
Relevance

●● Moderate

Performance concern is plausible, with recent session-storage precedents, but exact list-payload
precedent is absent.

PR-#1940 PR-#1927

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The cap allows up to 256 KiB of decisions per session, and one is recorded for every classified
statement. The list query projects the whole metadata column, and the list handler copies
Metadata into each item. The API caps limit at 100. Only the session details page reads
ai_decisions, through session-analysis/ai-decisions.

gateway/services/sidecarsessions.go[281-308] gateway/models/session.go[813-825] gateway/api/session/parser.go[30-53] gateway/api/session/parser.go[135-142] gateway/api/session/session.go[440-440]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Sidecar sessions now keep up to 256 KiB of `metadata.sidecar.ai_decisions`. The session list query (`ListSessions`) selects `s.metadata` in full and returns it for up to 100 rows, so list responses and DB reads grow by up to about 25 MiB per page. Only the session details page uses this data.

## Fix Focus Areas
- gateway/models/session.go[813-825]
- gateway/services/sidecarsessions.go[61-72]

## Recommended Fix
In the list query, project metadata without the decisions, for example `s.metadata #- '{sidecar,ai_decisions}' AS metadata`. Keep the full column in `GetSessionByID`, which the details page uses. You could also lower `maxSidecarAIDecisionsBytes`, or store the decisions in a separate column or table so each event batch does not rewrite the large jsonb metadata.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



7. Hand-off model call spends budget on statements later rules deny ✗ Dismissed
Description
EvaluateWith calls e.handoff(...) synchronously during evaluation, so the second model turn and
its takeCall() run before any later evaluator in the chain. The approval agent was built to wait
for those: requestAgentApproval defers through ec.RequestReview so its call runs only after
every later evaluator allowed. On a lane where a later guardrail or OPA rule denies a statement, the
hand-off still spends a max_calls or rate-limit slot and adds model latency. Once the budget runs
out, the hand-off fails safe to its most restrictive allowed action, or the lane denies statements
outright because it holds.
Code

sidecar/analyzer/evaluator.go[R601-606]

+	if action == ActionHandoff {
+		// risk_action stays ai-handoff; the decision and its reason ride
+		// beside it. A cached decision is reused, but a hold it selects
+		// still runs for this statement.
+		return e.applyHandoff(stmt, ec, notes, level, c, e.handoff(ctx, stmt, ec, level, c))
+	}
Relevance

●● Moderate

Recent evaluator precedents accept budget and concurrency fixes, but deferring handoff calls is
architectural and context-sensitive.

PR-#1892 PR-#1682

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
handoff calls takeCall and agentCall inline. The approval agent defers its call through
ec.RequestReview so it runs only after the later evaluators allowed. The hand-off path does not
defer, so later denials still cost a call. takeCall draws on the classification's shared budget.

sidecar/analyzer/handoff.go[255-276] sidecar/analyzer/approver.go[212-232] sidecar/analyzer/approver.go[342-362]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The hand-off agent's model call runs synchronously in `EvaluateWith`, before later evaluators. It spends `max_calls` and rate-limit budget even when a later rule denies the statement. The approval agent avoids this by deferring through `ec.RequestReview`.

## Fix Focus Areas
- sidecar/analyzer/evaluator.go[601-606]
- sidecar/analyzer/handoff.go[229-277]
- sidecar/analyzer/approver.go[217-232]

## Recommended Fix
When `ec != nil`, register the hand-off through `ec.RequestReview(policy.ReviewRequest{Resolve: ...})` so the model call and the `applyHandoff` result are produced only after the later evaluators allow. Fall back to the synchronous path only when there is no EvalContext. Keep the cache lookup before the deferral so a cache hit still costs nothing. If you keep the synchronous call on purpose, add a comment explaining why and note the budget cost in the README.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


8. Prompt input has no visible label 📘 Rule violation ☑ Accessibility
Description
PromptField renders the visible Your prompt text as a Text component while the textarea only
receives aria-label="Your prompt" and no associated label/id pair. Users who rely on the
required visible form label association cannot connect that prompt text to the focusable textarea
when the editor is opened.
Code

webapp_v2/src/pages/Features/AiSessionAnalyzer/components/PromptField.jsx[R22-45]

+          <Text size="sm">
+            <OptionalLabel>Your prompt</OptionalLabel>
+          </Text>
+          {!open && (
+            <Text size="xs" c="dimmed">
+              Uses the default prompt.
+            </Text>
+          )}
+        </Stack>
+        {open ? (
+          <Button variant="transparent" size="xs" px={0} leftSection={<Undo2 size={14} />} onClick={useDefault}>
+            Use default
+          </Button>
+        ) : (
+          <Button variant="transparent" size="xs" px={0} leftSection={<SquarePen size={14} />} onClick={() => setEditing(true)}>
+            Edit prompt
+          </Button>
+        )}
+      </Group>
+      {open && (
+        <Textarea
+          aria-label="Your prompt"
+          placeholder={placeholder}
+          description={helper}
Relevance

● Weak

A recent accessibility finding requiring visible label association was explicitly rejected in a
comparable UI context.

PR-#1961

ⓘ Recommendations generated based on similar findings in past PRs

Evidence
The newly added prompt field displays Your prompt inside a Mantine Text component, while the
interactive Textarea has no id and no visible label wrapper; it only sets aria-label. The
checklist requires each visible textarea to have an associated visible label or to be nested inside
one.

Rule 1800943: Associate each form input with a visible <label> element webapp_v2/src/pages/Features/AiSessionAnalyzer/components/PromptField.jsx[22-45] webapp_v2/src/pages/Features/AiSessionAnalyzer/components/PromptField.jsx[41-52]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The prompt textarea has only an `aria-label`; the visible `Your prompt` text is not a `<label>` associated with the control.

## Fix Focus Areas
- webapp_v2/src/pages/Features/AiSessionAnalyzer/components/PromptField.jsx[22-45]

## Recommended Fix
Give the textarea a stable `id` and render the visible `Your prompt` text through the component's visible label support or an explicit `<label htmlFor="...">`. Keep the label association intact for every rendered prompt field.

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Dismiss ↗ | View ↗


Context sources
✅ Compliance rules (platform): 180 rules
✅ Skills: build-ticket
✅ Cross-repo context — repo relationships
  Explored: repo: hoophq/documentation (sha: 52af6604) — View relationship
  Explored: repo: hoophq/internal-runtime-control (sha: f9e2bc6f) — View relationship
  Explored: repo: hoophq/hoop-benchmarks (sha: ec40a61e) — View relationship
  Explored: repo: hoophq/hoop-skills (sha: 372cfc31) — View relationship
  Explored: repo: hoophq/voight-kampff (sha: c6c71dc5) — View relationship
Review mode: Auto: 🧠 Deep: Dense cross-system feature with many independent logic and authorization paths.
Tip of the day
💡 Did you know, you can add REVIEW.md to your repo root and Qodo follows it on every PR

More tips ↗ | Customize Qodo ↗ | Qodo docs ↗

racerxdlreviewed4d ago
racerxdlreviewed4d ago
racerxdlreviewed4d ago
qodo-code-review[bot]reviewed4d ago
qodo-code-review[bot]reviewed4d ago
qodo-code-review[bot]reviewed4d ago
qodo-code-review[bot]reviewed4d ago
qodo-code-review[bot]reviewed4d ago
sandromello2d ago

✅ Build Completed with Success, Version=2065.0.0-g98897bd

This pull request is a draft

Merging is unavailable until it is marked ready for review.