Files
AutopilotMonitor/docs/backend/session-annotations.md
T
Oliver Kieselbach d6783fe327 feat: free-text note search on the annotation lists
Both annotation list routes (sessions/annotations/list, global/session-
annotations) accept ?q= -- a case-insensitive substring over the note and
the verdict, deliberately not the lane, author or rule ids, which have their
own filters. Table Storage has no contains, so it is applied in the same
client-of-Azure back-fill loop as ruleId (filtered-out rows never consume
page budget) and bound into the continuation fingerprint, so a cursor issued
for one search cannot be replayed against another; nextLink echoes it.

Portal: a debounced search box on the Annotations page (the setState lives
in the timer callback, not the effect body). MCP: list_session_annotations
gains `query` -> q. Response DTO unchanged, no wire break.

Why: demo sessions imported into the operator's own tenant fall out of every
dashboard/geo/SLA time window. A note ("wifi switch") plus this search is
how they are found again -- and it is generally useful for any judged session.

Deploy order: backend first, then web and MCP (the parameter is silently
ignored by an older backend).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-01 22:49:35 +02:00

8.7 KiB
Raw Blame History

type, title, description, resource, tags, timestamp
type title description resource tags timestamp
Concept Session Annotations — Role-Separated Human Verdicts The SessionAnnotations table (PK=tenantId, RK={sessionId}_{lane}) storing per-session human verdicts + notes in three role lanes (operator / tenantadmin / globaladmin), the per-lane write matrix with own-tenant binding, the platform-internal GA lane, the fired-rule-id snapshot that makes annotations rule-quality labels, and the portal/MCP surfaces. /src/Backend/AutopilotMonitor.Functions/Functions/Annotations/UpsertSessionAnnotationFunction.cs
backend
web
mcp
annotations
authorization
rule-quality
2026-08-11T00:00:00+02:00

Schema

Table. SessionAnnotations: PartitionKey = tenantId, RowKey = {sessionId}_{lane} with lane ∈ operator | tenantadmin | globaladmin (a closed set — AnnotationLanes.All). One row per session + lane, edited in place; per-session reads use the RK range ge '{sid}_' / lt '{sid}_~'. Serialization lives only in TableSessionAnnotationRepository.StoreAnnotation/MapAnnotation (round-trip-pinned; corrupt RuleIdsJson degrades to an empty list on read).

Row. Verdict (nullable: root_cause_confirmed | analysis_wrong | different_problem | inconclusive), Note (nullable, ≤ 4096 chars), AuthorUpn/AuthorDisplayName (last editor), CreatedByUpn/CreatedAtUtc (first writer, immutable across edits), UpdatedAtUtc, and RuleIdsJson — a snapshot of the rule ids that had fired for the session at write time (from IRuleRepository.GetRuleResultsAsync, fail-soft to empty). The snapshot is what turns an annotation into a rule-quality label without a join: filtering annotations by ruleId + verdict yields confirmed vs false-positive counts per rule even after results are re-analyzed or deleted.

Verdict + note are independently optional; a PUT with both null deletes the lane row (there is no DELETE route). Author identity is always stamped server-side from the JWT (TenantHelper.GetUserDisplayName + RequestContext.UserPrincipalName) — a body-supplied author never wins (same anti-spoof contract as the gather-rule PUT-upserts).

Authorization

Routes (all in EndpointAccessPolicyCatalog):

Route Policy Notes
GET sessions/{sessionId}/annotations MemberRead + QueryParam handler filters the GA lane
PUT sessions/{sessionId}/annotations/{lane} TenantAdminOrOperator + QueryParam re-gated per lane in-function
GET global/session-annotations GlobalReadOrAdmin + QueryParam evaluation stream; delegated ("MSP") readers are rescue-admitted on a managed ?tenantId= only

Per-lane write matrix (UpsertSessionAnnotationFunction.IsLaneWritableByCaller, an internal static pure function like QueueSessionActionFunction.IsTypeAllowedForCaller): operator → tenant Operator or tenant Admin; tenantadmin → tenant Admin; globaladmin → Global Admin only. Own-tenant binding: the session's tenant is resolved before the gate (GA cross-tenant fallback via FindSessionTenantIdAsync), and tenant-role inputs are zeroed when the resolved tenant differs from the caller's JWT tenant — so a GA who is also an admin of their home tenant can never write another tenant's operator/tenantadmin lanes. Cross-tenant, a GA writes exactly the globaladmin lane (the platform labeling flow).

GA lane is platform-internal. GetSessionAnnotationsFunction.FilterLanesForCaller drops the globaladmin lane for every caller without HasGlobalScope (tenant members and delegated admins). Both list endpoints enforce the same rule server-side via the repository's excludeGlobalAdminLane OData clause — on the global route this matters exactly for the delegated-rescue path, which reaches it without global scope. Tenant lanes are readable by all members of that tenant.

Audit. Non-GA writes/clears log LogAuditEntryAsync(..., "SessionAnnotation", "{sessionId}/{lane}", ...); GA writes are skipped (platform-internal labeling must not surface in the tenant-visible audit log — same convention as GA session-report submissions).

Lifecycle

  • Per-session cascade delete: three exact-RK steps (one per lane) in DeletionManifestBuilder (orders 17–19; inventory 20/21, tombstone 22). ComputePreflightCounts sums repeated-table steps so the sessionAnnotations count covers all lanes.
  • Tenant offboarding: SessionAnnotations is in the PK=tenantId wipe list — deliberately wiped, unlike Feedback (annotations are customer session data, not product feedback).
  • Backup: member of Constants.CriticalBackupTables.All — hand-labeled data is the most expensive data on the platform to lose.
  • Being in Constants.TableNames.All auto-creates the table at startup and exposes it to the raw list_tables/query_table tools.

Surfaces

  • Portal: SessionAnnotationsCard on the session detail page (#section-annotations, after Analysis; registered in BOTH the section div and the sessionSections sidebar registry). The card mirrors the matrix via the pure module sessionAnnotationLogic.ts (vitest-pinned) and uses the explicit-Save pattern; the backend re-gates every save. Collapsed by default with a count + verdict-pill summary header.
  • Portal overview page /annotations (Monitoring sidebar group — deliberately NO new "Insights" group; Fleet Health & co. are insights too and a second analytics group would dilute both): tenant-scoped list backed by GET sessions/annotations/list (ListTenantSessionAnnotationsFunction, MemberRead + QueryParam). The path sits under the /api/sessions client-cert exclusion prefix (rename-over-exclusion convention; the Azure exclusion list is portal-only config) and uses THREE segments because a two-segment literal would be eaten by the sessions/{sessionId} route template. The tenant is always the middleware-validated TargetTenantId, and for callers without global scope the repository adds a server-side Lane ne 'globaladmin' OData clause — hidden rows never consume page budget, and an explicit lane=globaladmin filter self-contradicts to an empty page. Rows deep-link to sessions?id=…#section-annotations. The page carries the standard tenant switcher (useAggregatedAdminScope + TenantScopeSelector): a GA/Reader defaults to the "All tenants" aggregate (the portal twin of MCP's list_session_annotations) with per-tenant drill-down, a delegated ("MSP") admin gets the managed subset; cross-tenant scopes route to GET global/session-annotations, a tenant column appears, and deep links carry the row's tenantId. A free-text search box (?q=, debounced) matches the note and the verdict case-insensitively — the way a session that has fallen out of every time window is found again: describe it once in a note ("wifi switch"), then search for that. Deliberately not the lane, author or rule ids, which have their own filters.
  • MCP read: get_session_summary carries an annotations key (4th parallel leg, fail-soft null); list_session_annotations (ga-gated) is the evaluation stream with tenantId / lane / verdict / ruleId / query / dateFrom / dateTo filters and nextLink pagination. The ruleId filter (substring on the JSON column) and the query note search (q on the wire, capped at 200 characters) are applied server-side but client-of-Azure, so the backend back-fills short pages by looping the Azure continuation (bounded rounds) — filtered-out rows never consume page budget. Both are bound into the continuation fingerprint, so a cursor issued for one search cannot be replayed against another.
  • MCP write: annotate_session (strictGa-gated, MUTATING) always PUTs the globaladmin lane — no lane argument by design. This is the frictionless labeling path after a session-debug.
  • Session reports: SessionReportService.SubmitReportAsync snapshots ALL lanes into annotations.json inside the report ZIP (annotationCount in report-metadata.json; fail-soft). Safe because report blobs are readable only through the GA-only session-reports routes, and valuable because the ZIP outlives the session's retention/delete.

Citations

  • src/Shared/AutopilotMonitor.Shared/Models/SessionAnnotation.cs — model, lanes, verdicts.
  • src/Backend/AutopilotMonitor.Functions/DataAccess/TableStorage/TableSessionAnnotationRepository.cs — Store/Map + query back-fill.
  • src/Backend/AutopilotMonitor.Functions/Functions/Annotations/ — the three endpoints.
  • src/Backend/AutopilotMonitor.Functions/Pagination/SessionAnnotationsPagination.cs — filter-bound continuation tokens.
  • src/Web/autopilot-monitor-web/app/sessions/components/sessionAnnotationLogic.ts — portal matrix mirror.
  • src/McpServer/autopilot-monitor-mcp/src/tools/admin.ts — list_session_annotations, annotate_session.