API & Reports

API endpoints (high-level)

Storage (Phase 36) + access control (Phase 37)

  • Analyses and comparisons are persisted in a SQLite file (default data/autopsy.db under the API content root, overridable with Storage:DatabasePath).
  • When Auth:Enabled is true, each row stores StoredArtifactAccess (owner user id, accessScope, sharedGroupIds, allowLinkAccess). Schema migration adds columns with safe defaults for existing deployments.
  • Storage:ArtifactTtlHours (optional): when greater than 0, rows get an expires_utc and are purged on read and at startup; 0 or omitted means no TTL in typical config (see appsettings.json).
  • Storage:MaxArtifactRows (optional): when greater than 0, oldest rows are deleted after inserts to cap total artifact rows (analyses + comparisons).
  • Payloads are full JSON snapshots of PlanAnalysisResult / PlanComparisonResultV2 (same shape as API responses)—not recomputed from raw plan text on read.
  • Phase 49 — artifact schema versioning: New writes stamp artifactSchemaVersion (integer; 0 = legacy implicit / unversioned rows) and optional artifactPersistedUtc. On read, the API runs PersistedArtifactNormalizer to upgrade legacy JSON to the current DTO shape where safe (suggestion family/backfills, compare carried-suggestion alsoKnownAs aliases for older deep-link ids, etc.). Unsupported future payload versions return 409; unreadable JSON returns 422 (row is not deleted). The SPA still applies a thin display normalization for missing suggestion fields as a safety net.

POST /api/analyze

  • When Auth:Enabled is true and RequireIdentityForWrites is true, requests without a resolved identity return 401 (authentication_required). Successful writes persist artifactAccess derived from config + identity.

  • body (either shape):

  • Preferred (Phase 35): { planText: string, queryText?: string | null, explainMetadata?: … } — planText is raw pasted text. The server runs PlanInputNormalizer: trims, accepts JSON starting with {/[, or strips a QUERY PLAN / psql table wrapper, joins lines ending in +, drops (N rows) footers, then parses JSON. On failure: 400 { error: "plan_parse_failed", message, hint }.
  • Legacy: { plan: <json element>, queryText?, explainMetadata? } — omit or leave planText empty; plan must be the JSON body. No planInputNormalization is set (null).
  • response: PlanAnalysisResult (includes queryText when provided; explainMetadata echoed when the client sent non-empty metadata after server normalization). When planText was used: planInputNormalization: { kind: "rawJson" | "queryPlanTable", detail? }.
  • summary.plannerCosts: string enum present | notDetected | mixed | unknown — inferred from whether plan nodes carry Startup Cost / Total Cost / Plan Rows / Plan Width (not from declared EXPLAIN options).
  • summary.hasBuffers: true if any plan node has at least one buffer counter (shared, local, or temp hit/read/dirtied/written fields). Populated from PostgreSQL’s flat per-node JSON keys and/or nested Buffers, with optional aggregation from Workers when the parent omits totals.
  • Each analyzed node’s node object may include workers: an array of typed per-worker stats (camelCase fields mirroring PlanWorkerStats) when the plan JSON contained a Workers array.
  • indexOverview, indexInsights, optimizationSuggestions as in prior phases.

GET /api/config (Phase 37 + 38)

  • Returns deployment flags for the SPA: authEnabled, authMode, authIdentityKind (none | proxy | legacy_bearer | jwt | api_key), authHelp (short non-secret summary of the active mode), requireIdentityForWrites, defaultAccessScope, rateLimitingEnabled, storage.databasePath. Used for share-link copy, the Sharing panel, and optional VITE_AUTH_* hints.

Auth identity (Phase 38)

  • JwtBearer: POST /api/analyze / POST /api/compare accept Authorization: Bearer <JWT>; owner ownerUserId is the validated sub (or configured subject claim), not the raw JWT string. Invalid Bearer JWT → 401 invalid_token when a token is sent.
  • ApiKey: send the key in X-Api-Key (or Auth:ApiKey:HeaderName); owner id comes from the api_key_principal SQLite table (keys stored as SHA-256 hashes). Unknown/disabled key → 401.
  • BearerSubject (legacy): entire bearer string remains the stored owner id (no JWT parsing). Prefer JwtBearer or ApiKey for production.

User preferences (Phase 40)

  • GET /api/me/preferences/{key} — when Auth:Enabled is false, 404 preferences_unavailable (SPA uses localStorage only). When auth is on, requires a resolved identity (401 without credentials); returns JSON { "value": … } where value is null or any stored JSON subtree. Keys are opaque strings (for example analyze_workspace_v1 for Analyze layout state, compare_workspace_v1 for Compare layout state). Values are versioned JSON blobs (visibility flags, section/column orders, optional preset id); the SPA coerces invalid stored orders back to safe defaults (Phase 42).
  • PUT /api/me/preferences/{key} — same gates; JSON body { "value": … }. Values persist in SQLite table user_preference in the same database file as artifacts (Storage:DatabasePath).

GET /api/analyses/{analysisId}

  • returns the stored PlanAnalysisResult (including artifactAccess, artifactSchemaVersion, artifactPersistedUtc when present); 404 { error: "analysis_not_found", analysisId } if missing or expired.
  • 422 { error: "artifact_corrupt", message, analysisId } if the row exists but JSON cannot be deserialized (corrupt or irreconcilable shape).
  • 409 { error: "artifact_version_unsupported", message, analysisId, schemaVersion, maxSupported } if the stored artifactSchemaVersion is newer than this server supports (or is explicitly rejected).
  • When Auth:Enabled is true, 403 { error: "access_denied", analysisId } if the caller is not allowed to read (owner / group / public / link policy per ArtifactAccessEvaluator).

GET /api/analyses

  • lists analysis ids (debug-oriented; ordering not a strict contract).

POST /api/compare

  • body (per side: text or JSON, not both required on a side if the other form is provided):
  • planAText / planBText (optional): same normalization as Analyze planText.
  • planA / planB: raw JSON elements (legacy).
  • queryTextA / queryTextB, explainMetadataA / explainMetadataB (optional): passed into AnalyzeAsync for that side before the comparison engine runs. Embedded planA / planB on the result carry query text, metadata, and planInputNormalization when applicable.
  • 400 { error: "plan_parse_failed", side, message, hint } if normalization fails for that side.
  • 400 { error: "plan_required", side, message } if a side has neither text nor JSON.
  • query: ?diagnostics=1 for matcher diagnostics.
  • response: PlanComparisonResultV2 (unchanged high-level shape; planA / planB are full analyze snapshots).

PUT /api/analyses/{analysisId}/sharing / PUT /api/comparisons/{comparisonId}/sharing (Phase 37)

  • Body: { "accessScope": "private"|"link"|"group"|"public", "sharedGroupIds": ["…"], "allowLinkAccess": bool }.
  • Requires Auth:Enabled; 401 without identity; 403 if not owner; 400 if auth disabled.
  • Success: 200 { ok: true, artifactId, accessScope, sharedGroupIds, allowLinkAccess }.

GET /api/comparisons/{comparisonId}

  • returns the stored comparison JSON (including artifactAccess, artifactSchemaVersion, artifactPersistedUtc when present); 404 { error: "comparison_not_found", comparisonId } if missing/expired.
  • 422 / 409 — same artifact_corrupt / artifact_version_unsupported contract as analyses (with comparisonId instead of analysisId).
  • When Auth:Enabled is true, 403 { error: "access_denied", comparisonId } when denied.

Compatibility matrix (persisted artifacts)

Situation HTTP error Notes
Missing / expired row 404 analysis_not_found / comparison_not_found
Auth denies read 403 access_denied
JSON parse / null payload 422 artifact_corrupt Row retained for recovery
Schema version too new 409 artifact_version_unsupported Includes schemaVersion, maxSupported
Legacy unversioned or older known shape 200 — Server normalizes to current response shape when possible

Reports

  • POST /api/report/markdown / POST /api/report/html / POST /api/report/json — body is { analysis: <PlanAnalysisResult> } (snapshot from a prior response or GET /api/analyses/{id}) or legacy { plan: … } for server-side analyze-from-JSON. Phase 116: ArtifactPersistenceJson.ApplyToHttpSerializerOptions copies ArtifactPersistenceJson.Options onto ConfigureHttpJsonOptions so analyze/compare report POST bodies use the same converters as SQLite persistence (and duplicate custom converters are not re-registered if the helper runs more than once). The SPA maps opaque 4xx export failures through formatApiErrorResponse for clearer user-facing lines.
  • Phase 117 / 118: Malformed JSON or unreadable bodies on /api/report/* and /api/compare/report/* return 400 JSON (not an empty body): request_body_invalid (could not parse/read the body) via ReportExportBadRequestExceptionHandler (IExceptionHandler); detail may include the raw server message in Development only. Phase 118: When the body parses but an analyze report request has neither analysis nor plan, export_request_incomplete is returned from ReportExportValidation.TryAnalyzeReportRequest (explicit Results.BadRequest, not exception-message parsing). Compare semantic errors (comparison_invalid, plan_required, etc.) remain Results.BadRequest from CompareExecution. Unhandled exceptions use the default ProblemDetails pipeline (AddProblemDetails + UseExceptionHandler()), not a bespoke catch-all 500 JSON handler. The SPA shows message without echoing error codes for request_body_invalid, export_request_incomplete, and 413 payload_too_large / request_too_large when a JSON message is present.
  • Phase 119: For 500 responses with RFC 7807-style JSON (title, optional detail) and no top-level message, formatApiErrorResponse builds a single calm line and avoids treating stack-like detail as user-facing text.
  • Phase 120 — compact 400 JSON shapes (report routes; message is the user-facing line the SPA prefers):
{ "error": "request_body_invalid", "message": "…" }
{ "error": "export_request_incomplete", "message": "…" }

When the body is empty or unreadable, clients may still receive 400 with no JSON; formatApiErrorResponse maps that to a single reload/re-paste hint (Phase 120). Phase 121: empty 401 responses map to a single sign-in / deployment hint; empty 413 uses the same calm tone as 400 (size limits, not alarmist wording). - POST /api/compare/report/markdown / POST /api/compare/report/html / POST /api/compare/report/json — body is either the same compare request shape as POST /api/compare (including planAText / planBText and per-side metadata) or (Phase 114) { comparison: <PlanComparisonResultV2> } only, so exports can render from a loaded snapshot without pasting plan text again (reopen-from-link parity). ?diagnostics=1 does not recompute matcher diagnostics when a snapshot is supplied—diagnostics are whatever the snapshot contains. Phase 115: Markdown/HTML exports include a compact server ## Reading thread / Change at a glance lead (from comparisonStory.overview, else a ranked new/worsened finding-diff line) before ComparisonId—the SPA may prepend a short Selection context (pair bridge + primary pair id) without duplicating that lead. HTML includes Top worsened/improved lines with Rewrite outcome when a pair verdict exists, plus Plan capture (per side), change briefing (with Story beats when the story has beats), bottleneck posture, narrative, an Index changes section (plan overview + insight diffs, aligned with the app — Phase 94), and a Next steps after this change block when compare-scoped suggestions exist (Phase 88 tightens markdown/HTML alignment).

Markdown report notes

  • Uses human-readable node labels in key places.
  • Includes a Source Query section when queryText is present in the analysis.
  • Adds Plan capture & EXPLAIN context with detected planner-cost presence and any declared explainMetadata (options line + optional recorded command).
  • Compare markdown adds a Plan capture & EXPLAIN context (per side) section summarizing Plan A and Plan B (query present/absent, input normalization kind, planner costs, declared options, recorded command). The SPA lists Next steps after this change inside a section landmark (role="region", aria-labelledby the h2) for clearer navigation (Phase 89).
  • Analyze markdown/HTML include an Optimization suggestions section when the engine produced items; Phase 86 HTML aligns with markdown on suggestion id, Why, and short Validate lines where present. Compare markdown adds Next steps after this change when compareOptimizationSuggestions is non-empty (family + optional Why line); compare HTML mirrors that block when suggestions exist (Phase 87 aligned the markdown heading with HTML).
  • POST /api/report/json and POST /api/compare/report/json return the same persisted shapes as GET artifacts (camelCase, ArtifactPersistenceJson options): analyze JSON includes planStory.inspectFirstSteps when the builder produced steps; compare JSON includes compareOptimizationSuggestions and per-pair rewriteVerdictOneLiner when present (Phase 87 tests lock the wiring).