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>
8.7 KiB
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 |
|
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).ComputePreflightCountssums repeated-table steps so thesessionAnnotationscount covers all lanes. - Tenant offboarding:
SessionAnnotationsis in the PK=tenantId wipe list — deliberately wiped, unlikeFeedback(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.Allauto-creates the table at startup and exposes it to the rawlist_tables/query_tabletools.
Surfaces
- Portal:
SessionAnnotationsCardon the session detail page (#section-annotations, after Analysis; registered in BOTH the section div and thesessionSectionssidebar registry). The card mirrors the matrix via the pure modulesessionAnnotationLogic.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 byGET sessions/annotations/list(ListTenantSessionAnnotationsFunction, MemberRead + QueryParam). The path sits under the/api/sessionsclient-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 thesessions/{sessionId}route template. The tenant is always the middleware-validatedTargetTenantId, and for callers without global scope the repository adds a server-sideLane 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 tosessions?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'slist_session_annotations) with per-tenant drill-down, a delegated ("MSP") admin gets the managed subset; cross-tenant scopes route toGET global/session-annotations, a tenant column appears, and deep links carry the row'stenantId. 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_summarycarries anannotationskey (4th parallel leg, fail-soft null);list_session_annotations(ga-gated) is the evaluation stream withtenantId / lane / verdict / ruleId / query / dateFrom / dateTofilters and nextLink pagination. TheruleIdfilter (substring on the JSON column) and thequerynote search (qon 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 theglobaladminlane — no lane argument by design. This is the frictionless labeling path after a session-debug. - Session reports:
SessionReportService.SubmitReportAsyncsnapshots ALL lanes intoannotations.jsoninside the report ZIP (annotationCountin 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.