@terreno/mcp 57.9.0 → 57.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (111) hide show
  1. package/dist/docs/upgrades/57.10.1.md +54 -0
  2. package/dist/docs/upgrades/57.11.0.md +80 -0
  3. package/dist/docs/versioned/next/README.md +1 -0
  4. package/dist/docs/versioned/next/explanation/README.md +4 -0
  5. package/dist/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
  6. package/dist/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
  7. package/dist/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
  8. package/dist/docs/versioned/next/explanation/ai-observability.md +101 -0
  9. package/dist/docs/versioned/next/explanation/durable-agent-harness.md +544 -0
  10. package/dist/docs/versioned/next/explanation/example-coverage.md +3 -1
  11. package/dist/docs/versioned/next/explanation/local-first-data.md +1 -0
  12. package/dist/docs/versioned/next/explanation/loop-engineering.md +1 -1
  13. package/dist/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
  14. package/dist/docs/versioned/next/explanation/typescript-configuration.md +1 -1
  15. package/dist/docs/versioned/next/explanation/versioning-policy.md +1 -1
  16. package/dist/docs/versioned/next/how-to/README.md +7 -0
  17. package/dist/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
  18. package/dist/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
  19. package/dist/docs/versioned/next/how-to/ai-feature-development.md +156 -0
  20. package/dist/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
  21. package/dist/docs/versioned/next/how-to/call-external-apis.md +1 -1
  22. package/dist/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
  23. package/dist/docs/versioned/next/how-to/circleci.md +5 -3
  24. package/dist/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
  25. package/dist/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
  26. package/dist/docs/versioned/next/how-to/preview-demo.md +7 -3
  27. package/dist/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
  28. package/dist/docs/versioned/next/how-to/run-tests-locally.md +2 -1
  29. package/dist/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
  30. package/dist/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
  31. package/dist/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
  32. package/dist/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
  33. package/dist/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
  34. package/dist/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
  35. package/dist/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
  36. package/dist/docs/versioned/next/reference/README.md +3 -0
  37. package/dist/docs/versioned/next/reference/admin-config.md +2 -0
  38. package/dist/docs/versioned/next/reference/admin-frontend.md +216 -0
  39. package/dist/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
  40. package/dist/docs/versioned/next/reference/ai-harness.md +1974 -0
  41. package/dist/docs/versioned/next/reference/ai.md +251 -25
  42. package/dist/docs/versioned/next/reference/api.md +110 -2
  43. package/dist/docs/versioned/next/reference/blocks.md +178 -0
  44. package/dist/docs/versioned/next/reference/environment-variables.md +2 -1
  45. package/dist/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
  46. package/dist/docs/versioned/next/reference/mcp-server.md +17 -0
  47. package/dist/docs/versioned/next/reference/syncdb.md +156 -6
  48. package/dist/docs/versioned/next/reference/test.md +11 -2
  49. package/dist/docs/versioned/next/reference/ui.md +331 -6
  50. package/dist/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
  51. package/dist/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
  52. package/dist/docs/versioned/next/tasks/ai-observability.md +233 -104
  53. package/dist/docs/versioned/next/tasks/durable-agent-harness.md +185 -0
  54. package/dist/tools.d.ts +4 -0
  55. package/dist/tools.d.ts.map +1 -1
  56. package/dist/tools.js +37 -0
  57. package/dist/tools.js.map +1 -1
  58. package/package.json +3 -2
  59. package/src/docs/upgrades/57.10.1.md +54 -0
  60. package/src/docs/upgrades/57.11.0.md +80 -0
  61. package/src/docs/versioned/next/README.md +1 -0
  62. package/src/docs/versioned/next/explanation/README.md +4 -0
  63. package/src/docs/versioned/next/explanation/admin-plugin-frontend.md +2 -2
  64. package/src/docs/versioned/next/explanation/agent-ui-asks.md +299 -0
  65. package/src/docs/versioned/next/explanation/agent-ui-blocks.md +67 -0
  66. package/src/docs/versioned/next/explanation/ai-observability.md +101 -0
  67. package/src/docs/versioned/next/explanation/durable-agent-harness.md +544 -0
  68. package/src/docs/versioned/next/explanation/example-coverage.md +3 -1
  69. package/src/docs/versioned/next/explanation/local-first-data.md +1 -0
  70. package/src/docs/versioned/next/explanation/loop-engineering.md +1 -1
  71. package/src/docs/versioned/next/explanation/roadmap-seed-issues.md +29 -1
  72. package/src/docs/versioned/next/explanation/typescript-configuration.md +1 -1
  73. package/src/docs/versioned/next/explanation/versioning-policy.md +1 -1
  74. package/src/docs/versioned/next/how-to/README.md +7 -0
  75. package/src/docs/versioned/next/how-to/agent-ui-asks.md +730 -0
  76. package/src/docs/versioned/next/how-to/agent-ui-blocks.md +104 -0
  77. package/src/docs/versioned/next/how-to/ai-feature-development.md +156 -0
  78. package/src/docs/versioned/next/how-to/build-a-durable-workflow.md +232 -0
  79. package/src/docs/versioned/next/how-to/call-external-apis.md +1 -1
  80. package/src/docs/versioned/next/how-to/charts-and-dashboards.md +2 -0
  81. package/src/docs/versioned/next/how-to/circleci.md +5 -3
  82. package/src/docs/versioned/next/how-to/migrate-rtk-to-syncdb.md +31 -1
  83. package/src/docs/versioned/next/how-to/observe-llm-calls.md +197 -0
  84. package/src/docs/versioned/next/how-to/preview-demo.md +7 -3
  85. package/src/docs/versioned/next/how-to/run-harness-on-multiple-instances.md +93 -0
  86. package/src/docs/versioned/next/how-to/run-tests-locally.md +2 -1
  87. package/src/docs/versioned/next/how-to/ship-a-new-task-version.md +76 -0
  88. package/src/docs/versioned/next/how-to/upgrade-terreno.md +1 -1
  89. package/src/docs/versioned/next/implementationPlans/agent-ui-asks.md +688 -0
  90. package/src/docs/versioned/next/implementationPlans/agent-ui-blocks.md +10 -6
  91. package/src/docs/versioned/next/implementationPlans/ai-observability.md +475 -157
  92. package/src/docs/versioned/next/implementationPlans/durable-agent-harness.md +340 -0
  93. package/src/docs/versioned/next/implementationPlans/terreno-langfuse-integration.md +1 -1
  94. package/src/docs/versioned/next/reference/README.md +3 -0
  95. package/src/docs/versioned/next/reference/admin-config.md +2 -0
  96. package/src/docs/versioned/next/reference/admin-frontend.md +216 -0
  97. package/src/docs/versioned/next/reference/agent-ui-asks.md +1059 -0
  98. package/src/docs/versioned/next/reference/ai-harness.md +1974 -0
  99. package/src/docs/versioned/next/reference/ai.md +251 -25
  100. package/src/docs/versioned/next/reference/api.md +110 -2
  101. package/src/docs/versioned/next/reference/blocks.md +178 -0
  102. package/src/docs/versioned/next/reference/environment-variables.md +2 -1
  103. package/src/docs/versioned/next/reference/lifecycle-plugin.md +11 -0
  104. package/src/docs/versioned/next/reference/mcp-server.md +17 -0
  105. package/src/docs/versioned/next/reference/syncdb.md +156 -6
  106. package/src/docs/versioned/next/reference/test.md +11 -2
  107. package/src/docs/versioned/next/reference/ui.md +331 -6
  108. package/src/docs/versioned/next/tasks/agent-ui-asks.md +213 -0
  109. package/src/docs/versioned/next/tasks/agent-ui-blocks.md +2 -1
  110. package/src/docs/versioned/next/tasks/ai-observability.md +233 -104
  111. package/src/docs/versioned/next/tasks/durable-agent-harness.md +185 -0
@@ -1,78 +1,146 @@
1
- # Implementation Plan: AI observability (Langfuse-light, pluggable)
1
+ # Implementation Plan: AI observability (Langfuse replacement, pluggable)
2
2
 
3
- **Status:** Draft
3
+ **Status:** Approved
4
4
  **Roadmap:** Area=`ai`, Target=`Future`, Impact=`Feature`
5
5
  **Created:** 2026-08-24
6
+ **Updated:** 2026-10-02 (domain-neutral RBAC and prompt relationship hub approved)
6
7
  **Owner:** unassigned
7
8
  **Branch:** `cursor/ai-observability-ip-64ca`
8
9
  **Task list:** [docs/tasks/ai-observability.md](../tasks/ai-observability.md)
10
+ **Design:** [AI Observability Prototype](https://claude.ai/design/p/36eec001-f2f0-4161-b60d-20dace4d0a9c?file=AI+Observability+Prototype.dc.html) — 21 screens, normative for UI and for the field/badge vocabulary below
9
11
  **Linear:** none
10
12
  **Roadmap issue:** none yet (handoff after Approved)
11
13
 
12
- **Depends on:** current `@terreno/ai` (`AIService`, `AIRequest`, `LangfuseApp`, `AIAdminApp`). The requested `ai-agents-and-failover` IP is **not in this repo**; this plan follows shipped AI code, not that missing doc.
14
+ **Depends on:** current `@terreno/ai` (`AIService`, `AIRequest`, `LangfuseApp`, `AIAdminApp`).
13
15
 
14
- **Related:** [terreno-langfuse-integration.md](./terreno-langfuse-integration.md) (`LangfuseApp` stays). [FH-48](https://linear.app/flourish-health/issue/FH-48/ai-improvements) (prompts in-app vs Langfuse).
16
+ **Related:** [terreno-langfuse-integration.md](./terreno-langfuse-integration.md) (`LangfuseApp` stays). [FH-48](https://linear.app/flourish-health/issue/FH-48/ai-improvements).
15
17
 
16
18
  ## Goal
17
19
 
18
- Ship **Langfuse-like** prompt versioning, nested traces (user / session / cost), multidimensional evaluators (LLM-as-judge + structured I/O), datasets, experiments, and a **local human review queue** — **inside Terreno**, as **plugins on `@terreno/ai`**, with an operator UI in **`admin-frontend` only**.
20
+ Replace Langfuse as the **primary** AI observability product for Terreno apps: prompt versioning with a
21
+ production label, nested traces with per-span cost, multidimensional evaluators, datasets, experiments with
22
+ gates, and a **human review queue** — all local (Mongo) with an operator UI in `admin-frontend`, and Langfuse
23
+ demoted to an optional **extra sink** an app can keep.
19
24
 
20
- Apps can:
25
+ The design prototype is the destination. Its 21 screens divide cleanly into three shippable products:
21
26
 
22
- - Run **fully local** (Mongo) with zero Langfuse keys.
23
- - Keep **`LangfuseApp` as a second plugin** (not deleted, not merged).
24
- - Fan out **traces and scores** to local + Langfuse + **OTLP/OpenInference** (Phoenix, Laminar, Arize AX, generic collectors).
25
- - Point **prompts, datasets, experiments** at local **or** Langfuse independently (with one mix rule).
27
+ | Phase | Ships | Design screens |
28
+ | --- | --- | --- |
29
+ | **1 — MVP** | Prompt library + production label, tracing, human review queue | Prompts, Prompt editor (+ Playground), Traces, Trace detail, Review queue, Review item |
30
+ | **2** | Evaluation loop: evaluators, datasets, experiments, in-app feedback | Evaluators (+ detail, new), Datasets (+ detail), Experiments (+ new, results) |
31
+ | **3** | Analytics + pluggable backends: Langfuse/OTel fan-out | Sessions (+ timeline), Users (+ AI profile), Costs, status chip primaries, Open in Langfuse |
32
+
33
+ Phase 1 alone must let a team stop copying prompt strings into route code, see what every AI call did and cost,
34
+ and get a clinician's judgement recorded against a real output. Phases 2 and 3 are additive — no Phase 1
35
+ schema or route is rewritten later.
26
36
 
27
37
  ## Non-goals
28
38
 
29
39
  - Removing or rewriting `LangfuseApp`.
30
40
  - `admin-spa` screens (spa already embeds `admin-frontend` widgets).
31
41
  - First-class Braintrust, Helicone, LangSmith, Weave, Opik, Portkey adapters (document how they fit; do not code stubs).
32
- - Langfuse **annotation queues** as `reviewQueue.primary` (local Mongo queue only in this IP).
42
+ - Langfuse **annotation queues** as `reviewQueue.primary` (local Mongo queue only).
33
43
  - Dual-write of prompt versions, datasets, or review items.
34
- - Retention / redaction / retry **jobs** (full I/O stored; Q9=C, Q13=A).
44
+ - Retention / redaction / retry **jobs** (full I/O stored; see Risks).
35
45
  - Replacing `AIRequest` (cheap call log remains).
36
46
  - Pixel-clone of Langfuse Cloud chrome.
47
+ - A "minimum datapoints" calculator. Operators keep their volume spreadsheet; Terreno stores provenance/tags so they can filter for balance.
48
+ - Auto-promoting a prompt to `production` when experiment gates pass. Promote stays an explicit admin action.
37
49
 
38
50
  ## Decisions
39
51
 
52
+ ### Carried forward
53
+
40
54
  | ID | Question | Choice |
41
55
  | --- | --- | --- |
42
- | Q1 | System of record | Mongo **local plugin** + **pluggable** backends (Langfuse and others), not wrap-only |
56
+ | Q1 | System of record | Mongo **local plugin** + **pluggable** backends, not wrap-only |
43
57
  | Q2 | Package | Grow `@terreno/ai` |
44
- | Q3 | Existing Langfuse | **Two plugins**: keep `LangfuseApp`; add local observability plugin |
58
+ | Q3 | Existing Langfuse | **Two plugins**: keep `LangfuseApp`; add `ObservabilityApp` |
45
59
  | Q4 | Operator UI | **`admin-frontend` only** |
46
- | Q5 | IP shape | **One IP**, full destination, **phased tasks** |
47
- | Q6 / Q11 | Dual receive | **Traces + scores + OTel** fan-out. Not prompts/datasets/queue |
48
60
  | Q7 | SDK | Capability interfaces + `capabilities` (comms-style) |
49
- | Q8 | v1 adapters | `local` (full) + `langfuse` (mapped) + **`otel` TraceSink** |
50
- | Q9 | PHI / I/O | Persist **full I/O**; **no** retention job in this IP |
51
- | Q10 | Langfuse UI | Terreno screens + **Open in Langfuse** for playground / Langfuse experiments |
52
- | Q12 / Q16 / Q21 | Primaries | Four flags: `prompts`, `datasets`, `experiments`, `reviewQueue` |
61
+ | Q8 | Adapters | `local` (full) + `langfuse` (mapped, phase 3) + `otel` TraceSink (phase 3) |
62
+ | Q9 | PHI / I/O | Persist **full I/O**; no retention job in this IP |
63
+ | Q12/Q16/Q21 | Primaries | Four flags: `prompts`, `datasets`, `experiments`, `reviewQueue` |
53
64
  | Q22 | Mix rule | `experiments.primary` **must equal** `datasets.primary` (fail config) |
54
- | Q23 | Defaults | All four primaries **`local`** when both plugins exist; Langfuse/OTel are extra **sinks** |
55
- | Q24 | Local off | **Allowed** (Langfuse-only app; no Mongo observability collections) |
56
- | Q25 | Review queue | **Local-only**; `reviewQueue.primary: "langfuse"` is a **config error** |
57
- | Q13 | Secondary down | Local (or primary write) **succeeds**; Langfuse/OTel errors **logged, never thrown** |
58
- | Q14 / Q17 | Experiments | Runner follows **`experiments.primary`**: local → always **`BackgroundTask`**; Langfuse → Langfuse experiment API |
65
+ | Q23 | Defaults | All four primaries **`local`**; Langfuse/OTel are extra **sinks** |
66
+ | Q25 | Review queue | **Local-only**; `reviewQueue.primary: "langfuse"` is a config error |
67
+ | Q13 | Secondary down | Primary write **succeeds**; secondary sink errors **logged, never thrown** |
68
+ | Q14/Q17 | Experiments | Local → always **`BackgroundTask`**; Langfuse → Langfuse experiment API |
59
69
  | Q15 | Mongo | **New collections**; keep `AIRequest` |
60
- | Q18 | Live evals | Interceptor with **`sampleRate` default `0`** |
61
- | Q19 | Cost | `inputTokens` / `outputTokens` / `model`; **USD from config price map** (missing model → tokens only) |
62
- | Q20 | Instrumentation | If observability is registered, **`AIService` emits traces** on every `generate*` (**opt-out per call**) |
70
+ | Q19 | Cost | `inputTokens` / `outputTokens` / `model`; USD from config price map (missing model → **tokens only**, never `$0`) |
71
+ | Q20 | Instrumentation | If observability is registered, `AIService` emits traces on every `generate*` (opt-out per call) |
72
+ | Q31 | Deploy plumbing | `AIService` `promptName` + `promptLabel` (default `"production"`) resolves via `PromptRegistry` before generate |
73
+
74
+ ### New — from the design prototype
75
+
76
+ | ID | Question | Choice |
77
+ | --- | --- | --- |
78
+ | Q34 | Prompt organization | Prompts live in a **folder** (`clinical-notes`, `safety`, …) plus free tags. Folder rail with counts is the primary filter; search matches name and tags |
79
+ | Q35 | Prompt list columns | Name (`folder/name`), type, latest, production, last used, **calls 7d**, **cost 7d** — the usage columns are a rollup over `ObsTrace`, computed on read with a short cache, not denormalized onto the prompt |
80
+ | Q36 | Version immutability affordance | The editor never edits in place: the only save action is **"Save as vN+1"**, and **"Set vN as production"** is a separate confirm modal naming the outgoing version. One production label per prompt |
81
+ | Q37 | Playground | Compiles the selected version with operator-supplied variables and runs **one** `AIService` call. Shows compiled messages, output, latency / tokens / cost, and **"Save this run to dataset"** (phase 2 wires the save) |
82
+ | Q38 | Sensitive content | Traces and spans carry `sensitive: boolean` (set per call, or inherited from the prompt version). Drives the `sensitive` badge, collapsed-by-default I/O disclosures, and the bulk-add warning. **Never** shown on the Users screen |
83
+ | Q39 | Evaluator shape | An evaluator declares `target` (`generation span` \| `full trace` \| `dataset item`) and **typed dimensions** `{key, dataType: numeric\|boolean\|categorical, range, required}`. Scores written by it carry exactly those keys |
84
+ | Q40 | Judge schema check | For `llm-judge`, the judge prompt version's `outputSchema` **must declare every required dimension**. Validated on create and on save, with the failing dimension named in the error. Not a runtime-only surprise |
85
+ | Q41 | Run modes | Per-evaluator, not global: `liveSampleRate` (0–100%, **default 0**), `availableInExperiments`, `allowManualRun`. Human evaluators **cannot** sample live traffic (rejected at save). Replaces the single global `AI_OBS_SAMPLE_RATE`, which becomes a **ceiling** |
86
+ | Q42 | Dataset provenance | Two operator-facing states: **human-annotated** (a person wrote or approved the expected output) and **auto-captured** (a rule or bulk add wrote it, unreviewed). Backed by `origin` (`manual` \| `trace` \| `synthetic`) + `proofread` + `annotatedBy`. Dataset detail tabs: All / Human / Auto / **Needs review** |
87
+ | Q43 | Sensitive → needs review | Adding a `sensitive` trace to a dataset always lands the item as auto-captured / needs review, whatever the bulk action was. The bulk bar states the count before the operator confirms |
88
+ | Q44 | Dataset input schema | A dataset may bind to a prompt (`inputSchemaPromptName`); items validate against that prompt version's `inputSchema` on add, and the detail header says so |
89
+ | Q45 | Experiment shape | Compare **2–3 prompt versions** on one dataset, not a strict A/B. Results show gate tiles, an outliers list, and a per-item side-by-side table with **failed items floated to the top** |
90
+ | Q46 | Promote gating | **Promote is blocked while any gate fails** — the button renders disabled with the failing gate named. Passing gates do not auto-promote; promote stays an explicit click plus a confirm modal |
91
+ | Q47 | Experiment wizard | Four steps — Dataset → Prompt versions → Evaluators → Review & run — with a completed-step rail, per-evaluator cost hints, and a **pre-run estimate** (generations, USD, wall clock) on the last step |
92
+ | Q48 | Review item presentation | The review screen is plain-language, not a JSON viewer: **"What the AI was given"** and **"What the AI wrote"** panels, per-field labels and reviewer notes, long fields collapsed with a word count, structured outputs as key/value rows, raw JSON behind a disclosure |
93
+ | Q49 | Review field metadata | Those labels and notes come from the prompt version: `variables[].label` / `.reviewerNote` for the given panel, `outputSchema` field titles + `outputFieldNotes` for the wrote panel. Falls back to raw keys when absent |
94
+ | Q50 | Review score form | Generated from the evaluator's dimensions: numeric → slider with end labels, boolean → Pass/Fail, categorical → pills. Plus an optional comment. Actions: **Submit & next**, Skip, Assign to me |
95
+ | Q51 | Queue flow | Tabs Pending / In progress / Done / Skipped with counts; oldest-first; **"Start reviewing — oldest first"**; item screen shows "Item N of M pending" with prev/next; explicit empty state naming both intake paths |
96
+ | Q52 | Queue intake | Two paths in phase 1: **"Send to human review"** on a trace (single and bulk) and manual assignment. Queue intake requires a human evaluator; its dimensions become reviewer score fields and its instructions guide scoring. Phase 2 adds experiments and in-app feedback flags |
97
+ | Q53 | Chrome | Breadcrumb `Admin / AI Observability / <Section> / <leaf>` on every screen, plus a status chip: local plugin state · Langfuse sink health · active primaries. **Open in Langfuse** appears only when the Langfuse plugin is registered (phase 3) |
98
+ | Q54 | Nav | One `AI Observability` sidebar group. **Review queue is hidden when the local plugin is off.** Existing `AI Requests` stays in its own group, untouched |
99
+ | Q55 | Evaluator templates | Seeded: `correctness`, `hallucination`, `helpfulness`, `toxicity` (llm-judge), `schema-assert` (json-assert, validates against the prompt version `outputSchema`, free/in-process), and one **human** review template. Custom evaluators still allowed |
100
+ | Q56 | Trace errors | A failed trace shows `error` status plus a one-line human cause on the row (`failed at span 3 of 4 — escalation-notify timed out`), derived from the first failed span |
101
+ | Q57 | Multi-prompt traces | A trace touching more than one prompt shows `N prompts` in the prompt column rather than one `name@version` |
102
+ | Q58 | Domain language | Observability workflows stay domain-neutral. Consumers supply folder meanings, tags, roles, and descriptions |
103
+ | Q59 | Approval workflow | Prompt approval remains outside Terreno for now. Terreno does not add an approval state or two-person promotion rule |
104
+ | Q60 | RBAC shape | Separate resources for `aiPrompt`, `aiTrace`, `aiReview`, `aiDataset`, `aiExperiment`, and `aiEvaluator`; consumers compose roles from their actions |
105
+ | Q61 | Read-only experience | A caller with read access sees the screen and data, while denied actions are hidden. A denied screen is omitted from admin config/navigation |
106
+ | Q62 | Prompt actions | `list`, `read`, `create`, `update`, `promote`, `playground` |
107
+ | Q63 | Other actions | Traces: `list`, `read`; reviews: `list`, `read`, `score`, `assign`; datasets/evaluators: CRUD; experiments: `list`, `read`, `create`, `promote` |
108
+ | Q64 | Existing roles | Auditor receives read/list through the existing read-only sentinel; Super Admin receives all; Admin is seeded with full observability access |
109
+ | Q65 | Existing admin users | Legacy `admin: true` remains a full-access fallback. RBAC-only users receive exactly their granted observability actions |
110
+ | Q66 | Prompt description | Prompt metadata gains an optional consumer-authored description. Folder remains consumer-defined |
111
+ | Q67 | Prompt hub | Prompt detail shows description plus related traces and experiments. Live relationships are derived from stored prompt name/version evidence rather than manually maintained usage records |
63
112
 
64
113
  ### Competitor coverage (why the SDK is split)
65
114
 
66
115
  | Backend | TraceSink | PromptRegistry | Dataset / experiment | ScoreSink | ReviewQueue |
67
116
  | --- | --- | --- | --- | --- | --- |
68
- | Local Mongo | yes | yes | yes | yes | yes (v1) |
69
- | Langfuse | yes | yes | yes | yes | no in v1 |
70
- | OTLP / OpenInference | yes | no | no | no | no |
117
+ | Local Mongo | yes (P1) | yes (P1) | yes (P2) | yes (P1) | yes (P1) |
118
+ | Langfuse | yes (P3) | yes (P3) | yes (P3) | yes (P3) | never |
119
+ | OTLP / OpenInference | yes (P3) | no | no | no | no |
71
120
  | Phoenix / Laminar / Datadog | via OTel | no | no | no | no |
72
121
  | Helicone / Portkey | later (gateway) | no | no | no | no |
73
122
  | Braintrust / LangSmith | later | later | later | later | later |
74
123
 
75
- Traces use **OpenTelemetry + OpenInference** span kinds (`LLM`, `AGENT`, `CHAIN`, `TOOL`, `EVALUATOR`, `PROMPT`). That is the honest “works with competitors” layer. Prompt/dataset/queue APIs stay vendor CRUD behind Terreno interfaces.
124
+ Traces use **OpenTelemetry + OpenInference** span kinds (`LLM`, `AGENT`, `CHAIN`, `TOOL`, `RETRIEVER`,
125
+ `EVALUATOR`). That is the honest "works with competitors" layer. Prompt/dataset/queue APIs stay vendor CRUD
126
+ behind Terreno interfaces.
127
+
128
+ ## Operator workflow (AI Feature Development SOP)
129
+
130
+ Normative operator path; how-to lives in [ai-feature-development.md](../how-to/ai-feature-development.md).
131
+
132
+ | SOP step | Operator action | Terreno provides | Phase |
133
+ | --- | --- | --- | --- |
134
+ | 1 Gather gold set | Live traces; bulk add to dataset; manual items; AI drafts | Tracing (P1); add-from-trace, import JSON, synthetic generate (P2) | 1 / 2 |
135
+ | 2 Label expected output | Hand-label outcomes; input/output schema | Dataset items, provenance, needs-review tab | 2 |
136
+ | 3 Create prompt | Product + optional AI draft | Prompt editor + playground; immutable versions | **1** |
137
+ | 4 Evaluators | Flags → correctness; open-ended → hallucination, helpfulness, toxicity, custom | Template install + evaluator builder | 2 (human type in **1**) |
138
+ | 5 Experiments | Run versions on a dataset; gates; outliers; iterate | Experiment wizard + results | 2 |
139
+ | 6 Deploy | Label winning version `production` | Label API + `promptName`/`promptLabel` resolution | **1** |
140
+ | 7 Live tracing + feedback | Watch production; thumbs and outcome class from the app | Auto-trace (P1); `POST feedback` (P2) | 1 / 2 |
141
+ | 8 Deficient traces → dataset | Filter weak traces; add; relabel; repeat 5–8 | Trace filters (P1); bulk add-to-dataset (P2) | 1 / 2 |
142
+
143
+ Phase 1 covers SOP steps 3, 6, 7 (tracing half) and the human-judgement loop; phase 2 closes 1, 2, 4, 5, 8.
76
144
 
77
145
  ## Architecture
78
146
 
@@ -96,7 +164,8 @@ ScoreSinks[] → fan-out with traces
96
164
  | Telemetry | all registered `TraceSink` / `ScoreSink` | fan-out | **local** if local plugin on; else Langfuse |
97
165
  | Control | primary per capability | **one** writer | that primary |
98
166
 
99
- Mirrors `@terreno/comms`: interfaces in `ai/src/observability/types.ts`; adapters do not put extra vendor SDKs on core beyond what `LangfuseApp` already uses. OTLP exporter is an optional adapter file.
167
+ Mirrors `@terreno/comms`: interfaces in `ai/src/observability/types.ts`; adapters add no vendor SDKs to core
168
+ beyond what `LangfuseApp` already uses. The OTLP exporter is an optional adapter file.
100
169
 
101
170
  ### Public types (normative)
102
171
 
@@ -129,6 +198,8 @@ interface ObservabilityPlugin {
129
198
  traceSink?: TraceSink;
130
199
  }
131
200
 
201
+ type SpanKind = "AGENT" | "CHAIN" | "EVALUATOR" | "LLM" | "RETRIEVER" | "TOOL";
202
+
132
203
  interface TraceRecord {
133
204
  id: string;
134
205
  name: string;
@@ -136,214 +207,461 @@ interface TraceRecord {
136
207
  endedAt?: string;
137
208
  userId?: string;
138
209
  sessionId?: string;
139
- status: "ok" | "error";
210
+ status: "error" | "ok";
211
+ /** First failing span rendered as one human line on the trace row (Q56). */
212
+ errorSummary?: string;
140
213
  input?: unknown;
141
214
  output?: unknown;
142
215
  metadata?: Record<string, unknown>;
216
+ /** Collapses I/O disclosures and forces dataset items to needs-review (Q38, Q43). */
217
+ sensitive: boolean;
143
218
  usage?: {
144
219
  inputTokens?: number;
145
220
  outputTokens?: number;
146
221
  model?: string;
222
+ /** Absent when the price map has no entry for `model` — render "tokens only", never $0. */
147
223
  costUsd?: number;
148
224
  };
149
- prompt?: {name: string; version: number; label?: string};
225
+ /** Multiple entries render as "N prompts" in the trace list (Q57). */
226
+ prompts: {name: string; version: number; label?: string}[];
227
+ flaggedForDataset?: boolean;
150
228
  spans: SpanRecord[];
151
229
  }
152
230
 
153
231
  interface ScoreRecord {
232
+ /** Must be a declared dimension key of `evaluatorId` when one is set (Q39). */
154
233
  name: string;
155
- dataType: "numeric" | "categorical" | "boolean";
156
- value: number | string | boolean;
234
+ dataType: "boolean" | "categorical" | "numeric";
235
+ value: boolean | number | string;
157
236
  comment?: string;
158
- source: "human" | "llm-judge" | "code";
237
+ /** 0–1. Experiment outlier if below the evaluator's `confidenceAlertBelow` (default 0.7). */
238
+ confidence?: number;
239
+ source: "code" | "human" | "llm-judge" | "user-feedback";
159
240
  evaluatorId?: string;
160
241
  traceId: string;
161
242
  spanId?: string;
162
243
  }
244
+
245
+ interface EvaluatorDimension {
246
+ dataType: "boolean" | "categorical" | "numeric";
247
+ key: string;
248
+ /** Numeric bounds, categorical values, or a free-text hint. Drives the review form control (Q50). */
249
+ range?: string;
250
+ required: boolean;
251
+ }
252
+
253
+ interface EvaluatorRunModes {
254
+ allowManualRun: boolean;
255
+ availableInExperiments: boolean;
256
+ /** 0–100. Default 0. Rejected as non-zero for `human` evaluators (Q41). */
257
+ liveSampleRate: number;
258
+ }
259
+
260
+ interface EvaluatorRecord {
261
+ confidenceAlertBelow?: number; // default 0.7
262
+ description?: string;
263
+ dimensions: EvaluatorDimension[];
264
+ /** json-assert only. */
265
+ assertion?: {constraint: string; path: string};
266
+ /** human only — shown atop the review score form. */
267
+ instructions?: string;
268
+ /** llm-judge only; its version's outputSchema must declare every required dimension (Q40). */
269
+ judgePromptName?: string;
270
+ name: string;
271
+ runModes: EvaluatorRunModes;
272
+ target: "dataset item" | "full trace" | "generation span";
273
+ type: "human" | "json-assert" | "llm-judge";
274
+ }
275
+
276
+ interface DatasetItemRecord {
277
+ /** Who wrote/approved the expected output; renders the provenance sub-line (Q42). */
278
+ annotatedBy?: {label: string; reviewItemId?: string; userId?: string};
279
+ datasetId: string;
280
+ expectedOutput?: unknown;
281
+ input: unknown;
282
+ metadata?: Record<string, unknown>;
283
+ origin: "manual" | "synthetic" | "trace";
284
+ outcomeClass?: "fn" | "fp" | "tn" | "tp";
285
+ /** true → "human-annotated"; false → "auto-captured" / needs review (Q42). */
286
+ proofread: boolean;
287
+ sourceTraceId?: string;
288
+ tags: string[];
289
+ }
290
+
291
+ interface ScoreThreshold {
292
+ aggregate: "mean" | "trueRate";
293
+ dimension: string;
294
+ evaluatorName: string;
295
+ op: "eq" | "gte" | "lte";
296
+ value: number;
297
+ }
298
+
299
+ /** Defaults applied to a new experiment. Boolean dimensions use trueRate; numeric use mean. */
300
+ const SOP_DEFAULT_THRESHOLDS: ScoreThreshold[] = [
301
+ {aggregate: "trueRate", dimension: "correct", evaluatorName: "correctness", op: "eq", value: 1},
302
+ {
303
+ aggregate: "trueRate",
304
+ dimension: "hallucinated",
305
+ evaluatorName: "hallucination",
306
+ op: "eq",
307
+ value: 0,
308
+ },
309
+ {aggregate: "mean", dimension: "helpfulness", evaluatorName: "helpfulness", op: "gte", value: 0.9},
310
+ {aggregate: "trueRate", dimension: "toxic", evaluatorName: "toxicity", op: "eq", value: 0},
311
+ ];
312
+
313
+ interface TraceFeedback {
314
+ comment?: string;
315
+ /** When true, mark trace `flaggedForDataset` and optionally enqueue review. */
316
+ flagDataset?: boolean;
317
+ kind: "flag_dataset" | "outcome" | "thumbs";
318
+ value: "down" | "fn" | "fp" | "tn" | "tp" | "up";
319
+ }
163
320
  ```
164
321
 
165
322
  `ObservabilityApp` (new `TerrenoPlugin`):
166
323
 
167
- - Validates config (Q22, Q25, plugin present for each primary).
324
+ - Validates config (Q22, Q25, plugin present for each primary) and fails boot loudly.
168
325
  - Holds the sink lists and primaries.
169
- - Contributes admin screens when local and/or Langfuse control APIs are on.
170
- - Does **not** replace `LangfuseApp.register()` — example-backend may `.register(new LangfuseApp(...)).register(new ObservabilityApp({plugins: [local, langfuseAdapter, otel]}))`. The Langfuse **adapter** wraps the existing client from `LangfuseApp` / `getLangfuseClient()`; it must not start a second OTel SDK if `LangfuseApp` already did.
326
+ - Contributes admin screens for the capabilities that are actually available.
327
+ - Does **not** replace `LangfuseApp.register()`. The Langfuse **adapter** wraps the existing client from
328
+ `LangfuseApp` / `getLangfuseClient()` and must not start a second OTel SDK.
171
329
 
172
- ### `AIService` hook
330
+ ### `AIService` prompt resolution and trace hook
173
331
 
174
- After each generate path (same place as `logRequest`):
332
+ Before every generate path:
175
333
 
176
- 1. Skip if no `ObservabilityApp` or `options.skipTrace === true`.
177
- 2. Build a root span (`CHAIN` or `LLM`) plus children for tool rounds when present.
178
- 3. Attach `userId`, `sessionId` from generate options (GPT routes copy from `req.user` / body).
179
- 4. Attach prompt name/version when `options.promptRef` is set.
180
- 5. Compute `costUsd` from `options.priceMap ?? observability.priceMap` keyed by `model`.
181
- 6. `Promise.allSettled` on each `TraceSink.export`; log rejections; **never throw**.
182
- 7. If `sampleRate > 0` and `Math.random() < sampleRate`, enqueue live evaluators (same BackgroundTask path as experiments, one trace).
334
+ 1. If `options.promptName` is set, resolve `PromptRegistry.get({name, label: options.promptLabel ?? "production"})`
335
+ **before calling the model**, independent of `skipTrace` and trace-sink registration. Use the resolved body and
336
+ retain its ref. Missing registry, missing label, or missing version → `APIError` 400 and no model call.
337
+ 2. Inherit `sensitive` from the resolved prompt version unless the generate option explicitly sets it.
338
+
339
+ After each successful or failed generate path (same place as `logRequest`):
183
340
 
184
- Parent/child workflows: `AIRequest` already has `parentRequestId` / `subRequestIds`. Traces use `traceId` + `parentSpanId`. Multi-agent `logMultiAgentRequest` should also emit a parent `AGENT`/`CHAIN` span wrapping children when observability is on.
341
+ 1. Skip **trace export only** if no `ObservabilityApp` or `options.skipTrace === true`; prompt resolution above still ran.
342
+ 2. Build a root span (`CHAIN` or `LLM`) plus children for tool rounds when present.
343
+ 3. Attach `userId`, `sessionId`, and resolved/effective `sensitive` from generate options.
344
+ 4. Attach the resolved ref to `trace.prompts`.
345
+ 5. Compute `costUsd` from `options.priceMap ?? observability.priceMap` keyed by `model`; leave undefined when absent.
346
+ 6. Set each span `status` and, for failures, its short `error` cause; derive trace `errorSummary` from the first failed
347
+ span in execution order.
348
+ 7. `Promise.allSettled` on each `TraceSink.export`; log rejections; **never throw**.
349
+ 8. Phase 2: for each evaluator with `liveSampleRate > 0` (capped by `AI_OBS_SAMPLE_RATE`), roll and enqueue a
350
+ live eval on the same `BackgroundTask` path experiments use.
351
+
352
+ `AIRequest` keeps `parentRequestId` / `subRequestIds`. Traces use `traceId` + `parentSpanId`. Multi-agent
353
+ `logMultiAgentRequest` also emits a parent `AGENT`/`CHAIN` span wrapping children.
185
354
 
186
355
  ## Models (local plugin)
187
356
 
188
- All new models: `createdUpdatedPlugin`, `isDeletedPlugin`, `findOneOrNone`, `findExactlyOne`, **description on every field**. Types in `ai/src/types/` (five-type pattern). Connection: default mongoose connection (same as `AIRequest`). If local plugin is not registered, **do not** register these models.
357
+ All new models: `createdUpdatedPlugin`, `isDeletedPlugin`, `findOneOrNone`, `findExactlyOne`, **description on
358
+ every field**. Types in `ai/src/types/` (five-type pattern). Default mongoose connection. Not registered when
359
+ the local plugin is absent.
189
360
 
190
- | Model | Role |
191
- | --- | --- |
192
- | `ObsPrompt` | Named prompt (`name` unique). Tags. |
193
- | `ObsPromptVersion` | Immutable version: `type` text\|chat, body, `variables[]`, `inputSchema`/`outputSchema` (JSON Schema objects), `config` (temperature preset name, model hint). |
194
- | `ObsPromptLabel` | Movable labels: `production`, `latest`, optional `staging`. Unique `(promptId, label)`. |
195
- | `ObsTrace` | Root trace: user, session, status, usage, prompt ref, timestamps. |
196
- | `ObsSpan` | Nested spans: `traceId`, `parentSpanId`, OpenInference `kind`, input/output, model, tokens, cost. |
197
- | `ObsScore` | Scores on trace/span. |
198
- | `ObsEvaluator` | Named grader: dimensions[], type human\|llm-judge\|json-assert, judge `promptName` + schema. |
199
- | `ObsDataset` / `ObsDatasetItem` | Items: input vars, optional expected output, `sourceTraceId`. |
200
- | `ObsExperiment` / `ObsExperimentItem` | Run metadata + per-item outputs/scores. Status queued\|running\|completed\|failed. `backgroundTaskId`. |
201
- | `ObsReviewItem` | Queue: pending\|in_progress\|done\|skipped, evaluatorId, traceId, assigneeId, scores payload. |
202
-
203
- **Do not** put a unique index on fields that can duplicate (scores per trace are many). Index `{created: -1, userId: 1}`, `{sessionId: 1, created: -1}`, `{promptName: 1, promptVersion: 1}`, `{status: 1, created: 1}` on review items.
361
+ | Model | Phase | Role |
362
+ | --- | --- | --- |
363
+ | `ObsPrompt` | 1 | Named prompt (`name` unique). **`folder`**, `tags[]`, optional domain-neutral `description`. |
364
+ | `ObsPromptVersion` | 1 | Immutable version: `type` text\|chat, `system`, `template`, `variables[]` (`{key, required, label?, reviewerNote?}`), `inputSchema`/`outputSchema` (JSON Schema), `outputFieldNotes` (Q49), **`sensitive: boolean`** (default false, inherited by calls), `config` (temperature preset, model hint). |
365
+ | `ObsPromptLabel` | 1 | Movable labels: `production`, `latest`, optional `staging`. Unique `(promptId, label)`. |
366
+ | `ObsTrace` | 1 | Root trace: user, session, status, `errorSummary`, `sensitive`, usage, `prompts[]`, timestamps. |
367
+ | `ObsSpan` | 1 | Nested spans: `traceId`, `parentSpanId`, OpenInference `kind`, input/output, model, tokens, cost, `sensitive`, **`status` (`ok`\|`error`) and optional short `error`**, `startOffsetMs`/`durationMs` for the waterfall. |
368
+ | `ObsScore` | 1 | Scores on trace/span. Many per trace — **no unique index**. |
369
+ | `ObsEvaluator` | 1 (human) / 2 (judge, assert) | `type`, `target`, `dimensions[]`, `runModes`, judge/assertion/instructions, `confidenceAlertBelow`. |
370
+ | `ObsReviewItem` | 1 | Queue: `status` pending\|in_progress\|done\|skipped, `evaluatorId`, `traceId`, `spanId?`, `datasetItemId?`, `assigneeId`, `reason` (`manual` \| `eval` \| `feedback` \| `dataset_candidate`), scores payload, `comment`, `enqueuedAt`. |
371
+ | `ObsDataset` | 2 | Name, tags, `inputSchemaPromptName`, `expectedOutputSchema`. |
372
+ | `ObsDatasetItem` | 2 | `input`, `expectedOutput`, `sourceTraceId`, `origin`, `proofread`, `annotatedBy`, `tags[]`, `outcomeClass`, `metadata`. |
373
+ | `ObsExperiment` / `ObsExperimentItem` | 2 | Run metadata + per-item outputs/scores per version. `versions[]` (2–3), optional `modelOverride`, `thresholds[]`, status, `backgroundTaskId`, aggregates incl. gate pass/fail, outlier ids, low-confidence ids, `estimate`. |
374
+
375
+ Indexes: `{created: -1, userId: 1}`, `{sessionId: 1, created: -1}` and `{status: 1, created: -1}` on traces;
376
+ `{"prompts.name": 1, "prompts.version": 1}` on traces (prompt rollups, Q35); `{traceId: 1}` on spans and scores;
377
+ `{status: 1, enqueuedAt: 1}` on review items; `{flaggedForDataset: 1, created: -1}` on traces;
378
+ `{datasetId: 1, proofread: 1, origin: 1}` on dataset items.
204
379
 
205
380
  No backfill: new collections. `AIRequest` unchanged.
206
381
 
207
382
  ## APIs
208
383
 
209
- Admin-only unless noted (`Permissions.IsAdmin`). OpenAPI via `createOpenApiBuilder`. Base path `/ai/observability` (stable, not `/admin/langfuse`).
384
+ OpenAPI uses `createOpenApiBuilder`. Base path is `/ai/observability`. When an
385
+ `accessControl` is supplied, each route enforces the resource/action matrix in Q60–Q63. Without
386
+ RBAC, the legacy `admin: true` check remains the compatibility path.
387
+
388
+ ### Phase 1
389
+
390
+ | Method | Path | Behavior |
391
+ | --- | --- | --- |
392
+ | GET | `/status` | Plugin ids, capabilities, primaries, local-on flag (drives chip + nav, Q53/Q54) |
393
+ | GET/POST | `/prompts` | List (folder, search, `include=usage7d` for calls/cost rollups) / create |
394
+ | GET | `/prompts/:name` | Prompt + versions + labels |
395
+ | POST | `/prompts/:name/versions` | Creates `vN+1`; never mutates an existing version |
396
+ | POST | `/prompts/:name/labels` | Move `production` / `staging`; returns the outgoing version for the confirm modal |
397
+ | POST | `/prompts/:name/playground` | Compile + one `AIService` call; returns compiled messages, output, latency, tokens, cost. Creates no version |
398
+ | GET | `/traces` | Filters: time range, prompt, status, user or session id, `hasScore`, `sensitive`, `flaggedForDataset`. Paginated |
399
+ | GET | `/traces/:id` | Span tree (kind, offsets, durations) + per-span I/O + scores |
400
+ | POST | `/traces/review` | Enqueue one or many traces against a human evaluator (Q52) |
401
+ | POST | `/traces/:id/scores` | Human or code score → all ScoreSinks |
402
+ | CRUD | `/evaluators` | Human type in phase 1; `GET /evaluators/templates` lists the seeds |
403
+ | GET | `/review` | Queue by status with counts; oldest-first |
404
+ | GET | `/review/:id` | Item + rendered `given` / `wrote` panels (Q48/Q49) + evaluator dimensions for the form |
405
+ | POST | `/review/:id` | `submit` (scores + comment), `skip`, or `assign` |
406
+
407
+ ### Phase 2
408
+
409
+ | Method | Path | Behavior |
410
+ | --- | --- | --- |
411
+ | POST | `/traces/:id/feedback` | **IsAuthenticated** (owner or admin). Thumbs / outcome / flag-for-dataset → ScoreSinks; optional review enqueue |
412
+ | POST | `/traces/add-to-dataset` | `{datasetId, traceIds[]}`. Copies I/O; `origin: "trace"`; sensitive traces forced to `proofread: false` (Q43) |
413
+ | CRUD | `/datasets` + `/datasets/:id/items` | Item PATCH for expectedOutput / tags / outcomeClass / proofread |
414
+ | POST | `/datasets/:id/import` | JSON or CSV upload; validates against `inputSchemaPromptName` when set. CSV: `text/csv` body or `{format: "csv", content}` |
415
+ | POST | `/datasets/:id/generate` | Synthetic drafts via named generator prompt; `proofread: false` |
416
+ | POST | `/experiments/estimate` | Wizard step 4: generation count, USD, wall-clock estimate |
417
+ | POST | `/experiments` | 2–3 versions × dataset × evaluators + thresholds; local → `BackgroundTask` |
418
+ | GET | `/experiments/:id` | Status, progress, aggregates, gate pass/fail, outliers, per-item side-by-side |
419
+ | POST | `/experiments/:id/promote` | **409 while any gate fails** (Q46); otherwise moves the production label |
420
+
421
+ ### Phase 3
210
422
 
211
423
  | Method | Path | Behavior |
212
424
  | --- | --- | --- |
213
- | GET | `/ai/observability/status` | Plugin ids, capabilities, primaries, sink health (last error) |
214
- | GET/POST | `/ai/observability/prompts` | List/create via **prompt primary** |
215
- | GET/POST | `/ai/observability/prompts/:name/versions` | Immutable versions; POST creates `vN+1` |
216
- | POST | `/ai/observability/prompts/:name/labels` | Move `production` / `staging` |
217
- | POST | `/ai/observability/prompts/:name/playground` | Compile + one `AIService` call (not a new version) |
218
- | GET | `/ai/observability/traces` | Filters: time, user, session, prompt, status, minCost, hasScore |
219
- | GET | `/ai/observability/traces/:id` | Tree + scores |
220
- | GET | `/ai/observability/sessions/:sessionId` | Timeline |
221
- | GET | `/ai/observability/users/:userId` | Totals 7d/30d/all + recent traces |
222
- | GET | `/ai/observability/costs` | Aggregates by model, prompt, user |
223
- | CRUD | `/ai/observability/evaluators` | Dimension definitions |
224
- | POST | `/ai/observability/traces/:id/scores` | Human or code score → **all ScoreSinks** |
225
- | CRUD | `/ai/observability/datasets` + items | Dataset **primary** |
226
- | POST | `/ai/observability/experiments` | Creates run; local → `BackgroundTask`; Langfuse → vendor API |
227
- | GET | `/ai/observability/experiments/:id` | Status + aggregates |
228
- | GET/POST | `/ai/observability/review` | Local queue only |
229
-
230
- GPT routes: pass `userId` / `sessionId` / optional `promptRef` into `AIService`.
231
-
232
- Langfuse-primary prompt/dataset/experiment routes **proxy** existing Langfuse admin routes where they already exist (`langfuseRoutesPrompts.ts` etc.) so we do not duplicate HTTP. ObservabilityApp may mount a thin facade that delegates.
425
+ | GET | `/sessions`, `/sessions/:id` | Session list + timeline |
426
+ | GET | `/users`, `/users/:id` | AI usage per user; masked identifiers, **no prompt content** (Q38) |
427
+ | GET | `/costs` | KPI row + by-model (`tokens only` when unpriced) + by-prompt |
428
+ | GET | `/status` | Extended with sink health (last error per sink) and Langfuse deep-link bases |
429
+
430
+ GPT routes pass `userId` / `sessionId` into `AIService`; request `sensitive: true` may upgrade handling,
431
+ but `false` never downgrades a sensitive registry version. Client `promptName` + `promptLabel`
432
+ selection is admin-only; application routes select production prompts server-side.
433
+ Langfuse-primary prompt/dataset/experiment routes **proxy** the existing Langfuse admin routes rather than
434
+ duplicating HTTP.
233
435
 
234
436
  ## UI (`admin-frontend`)
235
437
 
236
- New screens in `AI_ADMIN_WIDGETS` / `customScreens` (same pattern as `AIRequestsScreenWidget`):
438
+ New screens registered in `AI_ADMIN_WIDGETS` / `customScreens`, same pattern as `AIRequestsScreenWidget`.
439
+ Chrome for every screen: breadcrumb `Admin / AI Observability / <Section> / <leaf>` and the status chip (Q53).
440
+ Built from `@terreno/ui` (`Page`, `Box`, `DataTable`, `Modal`, `TextField`, `Badge`, `Banner`) — the design's
441
+ palette is already the Terreno theme (`#2B6072` rail, `#0E9DCD` primary, Titillium/Nunito).
442
+
443
+ ### Phase 1
444
+
445
+ | Screen | Name | Design requirements |
446
+ | --- | --- | --- |
447
+ | Prompts | `ai-prompts` | Folder rail with counts, search, table (folder/name, type badge, latest, production, last used, calls 7d, cost 7d). Column tooltips explain latest vs production. `—` when no production label |
448
+ | Prompt editor | `ai-prompt-editor` | Full-width version history with one selectable row per version (`vN`, all attached labels, creation time, selected highlight); Editor / Playground tabs; system + user template; variables and output schema summary; temperature preset + model hint; **Save as vN+1**; **Set vN as production…** modal naming the outgoing version; "one production label per prompt" hint |
449
+ | Playground | (tab) | Variable inputs from `variables[]`; Run once with running state; compiled-messages disclosure; output; latency / tokens / cost; **Save this run to dataset** (disabled until phase 2) |
450
+ | Traces | `ai-traces` | Filter bar (time, prompt/status/score/sensitivity dropdowns, user/session); checkbox selection with a bulk bar — **Send to human review** opens an explanatory human-evaluator picker, Add to dataset (phase 2), sensitive count warning, Clear; rows show status dot, `sensitive` badge, error line, `N prompts` for multi-prompt, spans / tokens / cost / latency / scores; pagination footer |
451
+ | Trace detail | `ai-trace-detail` | Header (name, status, sensitive, ids, user/session links, span/token/cost/latency summary) + actions; left span list with kind badge, indent, and duration bar; right span detail (model, prompt link, tokens, cost, latency) with **collapsed** Input/Output disclosures marked sensitive; scores panel with value + source |
452
+ | Review queue | `ai-review` | Tabs Pending / In progress / Done / Skipped with counts; table (item, what the AI did, prompt, assignee, waiting, status); **Start reviewing — oldest first**; empty state naming both intake paths. **Hidden when the local plugin is off** |
453
+ | Review item | `ai-review-item` | Header with position "Item N of M pending" + prev/next; read-only **What the AI was given** / **What the AI wrote** panels (long fields collapsed with word count and expand, structured output as key/value rows, per-field reviewer notes); raw JSON disclosure; score form generated from the evaluator's dimensions (slider / Pass-Fail / pills) with the evaluator's instructions on top; comment; **Submit & next**, Skip, Assign to me; toast reports remaining count and "Queue clear" at zero |
454
+
455
+ ### Phase 2
237
456
 
238
- | Screen | Name | Notes |
457
+ | Screen | Name | Design requirements |
458
+ | --- | --- | --- |
459
+ | Evaluators | `ai-evaluators` | Table (name, type badge, dimensions summary, target, run-mode chips) + the "schema is checked on save / live N% bills judge calls" footnote |
460
+ | Evaluator detail | `ai-evaluator-detail` | Dimensions table (key, data type, range, required); type-specific panel (judge prompt link + model + **schema-match check**, assertion path/constraint, reviewer instructions); run modes with the live-sampling slider and its cost warning; **Used by** list with 30-day runs and cost |
461
+ | New evaluator | `ai-evaluator-new` | Intro explaining what an evaluator is; type and target pickers with per-choice helper text; dimension builder (key, data type pills, range, required, remove) with field helpers; type-specific panel with helpers; **inline schema mismatch error naming the undeclared dimension**; run modes; Create |
462
+ | Datasets | `ai-datasets` | Legend for human-annotated vs auto-captured; table (name, items, provenance bar + counts, source, updated); Import JSON or CSV; New dataset |
463
+ | Dataset detail | `ai-dataset-detail` | Header with human/auto counts and the input-schema binding line; Add item; Run experiment; tabs All / Human / Auto / **Needs review** (red count); items table (id, input, expected output, provenance + attribution line, trace link) |
464
+ | Experiments | `ai-experiments` | Table (id, name, dataset, status badge, progress bar for running, cost); footnote on BackgroundTask / Langfuse deep-link |
465
+ | New experiment | `ai-experiment-new` | 4-step rail with completed summaries; dataset picker showing item counts and schema match; version checkboxes tagged latest/production/superseded; **Next and later rail steps disabled until 2–3 versions are selected**; evaluator checkboxes with per-item cost; **Review & run** summary with the estimate |
466
+ | Experiment results | `ai-experiment-results` | Gate tiles (value, gate verdict); "N gates failing" badge; **Promote blocked** state naming the failing gate; outliers list with reason chips; side-by-side per-item table with failed rows floated and highlighted; promote confirm modal |
467
+
468
+ ### Phase 3
469
+
470
+ | Screen | Name | Design requirements |
239
471
  | --- | --- | --- |
240
- | Prompts | `ai-prompts` | List + editor (new version, labels, JSON Schema I/O, playground) |
241
- | Traces | `ai-traces` | List + detail waterfall |
242
- | Sessions | `ai-sessions` | Optional v1: filter traces by session from detail |
243
- | Costs | `ai-costs` | KPI + breakdown tables |
244
- | Evaluators | `ai-evaluators` | Dimension builder |
245
- | Datasets | `ai-datasets` | |
246
- | Experiments | `ai-experiments` | Wizard; poll BackgroundTask; promote version |
247
- | Review | `ai-review` | Split view; **hide** if local plugin off |
248
- | Status chip | chrome | Active primaries; **Open in Langfuse** when Langfuse plugin id present |
472
+ | Sessions | `ai-sessions` | Traces/Sessions toggle; table (session, user, started, traces, duration, total cost) |
473
+ | Session timeline | `ai-session-detail` | Header totals; vertical timeline of traces with time, name, human note, cost, latency |
474
+ | Users | `ai-users` | Subtitle "AI cost and usage per user — not the user CRUD table"; table (id, name, traces 7d, tokens 7d, cost 7d) |
475
+ | User AI profile | `ai-user-detail` | Avatar + masked email + "no raw prompt content on this page"; 7d / 30d / all-time rollups; recent sessions and recent traces |
476
+ | Costs | `ai-costs` | KPI row (today, 7d, 30d, avg/trace); by-model table with **tokens only** for unpriced models plus the "no $0 lie" note; by-prompt table |
477
+ | Chip / deep links | chrome | Sink health in the chip; **Open in Langfuse** when the Langfuse plugin is present |
478
+
479
+ Keep the existing **AI Requests** explorer as-is.
480
+
481
+ ### Domain-neutral access and prompt hub
249
482
 
250
- Reuse `@terreno/ui` (`Page`, `Box`, `DataTable`, `Tabs`, `Modal`, `TextField`). Keep **AI Requests** explorer as-is (`AIRequest`).
483
+ `ObservabilityApp` accepts the application's Terreno access object. Its admin contribution declares
484
+ `adminAccess` per screen, so `/admin/config` and the sidebar omit resources the caller cannot list or
485
+ read. Detail and mutation routes enforce the corresponding action independently; hiding a button is
486
+ not authorization.
251
487
 
252
- Disabled tabs: if prompt primary is Langfuse, editor still works via API facade; if local off, Prompts/Datasets/Experiments/Review either hide or show Langfuse-backed lists + deep links (Q10). Review hidden when local off (Q25).
488
+ The prompt detail screen is the relationship hub:
489
+
490
+ - Overview shows folder, tags, optional description, and current production/latest versions.
491
+ - Versions keeps the immutable editor and history.
492
+ - Traces lists calls related to this prompt and supports version filtering.
493
+ - Experiments lists runs related to this prompt without loading unrelated experiments.
494
+ - Read-only callers see these tabs but no create-version, playground, or promote controls.
495
+
496
+ Folders, descriptions, tags, and role names remain consumer-defined. Terreno introduces no
497
+ clinical, finance, or other domain vocabulary.
253
498
 
254
499
  ## Example app
255
500
 
256
501
  `example-backend`:
257
502
 
258
- - Register `ObservabilityApp` with **local plugin always**.
259
- - If `LANGFUSE_*` already used for `LangfuseApp`, also pass Langfuse TraceSink/ScoreSink (and prompt adapter only if env `AI_OBS_PROMPTS_PRIMARY=langfuse`).
260
- - Optional `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` → `OtelTraceSink`.
261
- - Seed one prompt `example-summarize` v1 production, one evaluator `faithfulness` (numeric 0–1) + `safety` (boolean), empty dataset.
503
+ - Register `ObservabilityApp` with the **local plugin always** (phase 1) and pass the same
504
+ `accessControl: access` object as `TerrenoApp` / `AdminApp` (phase 4).
505
+ - Seed prompt `example-summarize` v1 labelled production, in folder `examples`.
506
+ - Seed the human evaluator template so the review queue is walkable on a fresh DB (phase 1).
507
+ - Seed the judge/assert evaluator templates and one dataset `example-gold` (phase 2).
508
+ - Add Langfuse sinks when `LANGFUSE_*` is set and `OtelTraceSink` when `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` is set (phase 3).
509
+ - Seed optional example roles `aiObservabilityViewer` and `aiObservabilityOperator` in
510
+ `rbacRoles.ts` without auto-assigning them (phase 4); integration tests prove seeded `admin` /
511
+ `superadmin` observability access and that the viewer role is read-only on HTTP.
262
512
 
263
- `example-frontend`: no product UI required (admin-only). Regen SDK if example-frontend calls new routes (likely admin-only; skip consumer SDK if unused).
513
+ `example-frontend`: admin-only; regenerate the SDK only if it ends up calling new routes.
264
514
 
265
515
  ## Feature flags & env
266
516
 
267
- No OpenFeature flag required: **registration is the flag**.
517
+ Registration is the flag — no OpenFeature gate.
268
518
 
269
- Env (example-backend):
270
-
271
- | Variable | Role |
272
- | --- | --- |
273
- | `AI_OBS_PROMPTS_PRIMARY` | `local` \| `langfuse` (default `local`) |
274
- | `AI_OBS_DATASETS_PRIMARY` | default `local` |
275
- | `AI_OBS_EXPERIMENTS_PRIMARY` | must equal datasets |
276
- | `AI_OBS_SAMPLE_RATE` | `0`–`1`, default `0` |
277
- | `AI_OBS_PRICE_MAP_JSON` | `{ "gemini-3.8-flash": { "inputPerMTok": 0.1, "outputPerMTok": 0.4 } }` |
278
- | existing Langfuse keys | unchanged |
279
- | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | optional OTLP |
519
+ | Variable | Phase | Role |
520
+ | --- | --- | --- |
521
+ | `AI_OBS_PRICE_MAP_JSON` | 1 | `{"gemini-2.5-flash": {"inputPerMTok": 0.1, "outputPerMTok": 0.4}}` |
522
+ | `AI_OBS_SAMPLE_RATE` | 2 | Global **ceiling** on per-evaluator `liveSampleRate`; default `0` |
523
+ | `AI_OBS_PROMPTS_PRIMARY` | 3 | `local` \| `langfuse` (default `local`) |
524
+ | `AI_OBS_DATASETS_PRIMARY` | 3 | default `local` |
525
+ | `AI_OBS_EXPERIMENTS_PRIMARY` | 3 | must equal datasets |
526
+ | existing Langfuse keys | 3 | unchanged |
527
+ | `OTEL_EXPORTER_OTLP_TRACES_ENDPOINT` | 3 | optional OTLP |
280
528
 
281
- Validate dataset/experiment primary equality and reject `reviewQueue` langfuse at boot with a clear `APIError` / logger.error + throw (fail fast like missing SendGrid key).
529
+ Validate dataset/experiment primary equality and reject a `langfuse` review queue at boot with a clear
530
+ `logger.error` and `APIError` throw, the way a missing SendGrid key fails.
282
531
 
283
532
  ## Activity log
284
533
 
285
- Do **not** add `UserUpdate` spam for every trace. Admin audit: prompt **production** label moves and experiment **promote** (reuse `onAdminAudit` if AdminApp is present; otherwise `logger.info` with prompt name + version).
534
+ No `UserUpdate` spam per trace. Admin audit only for prompt **production** label moves and experiment
535
+ **promote** (reuse `onAdminAudit` when AdminApp is present; otherwise `logger.info` with prompt name + version).
286
536
 
287
537
  ## Testing
288
538
 
289
539
  - Bun tests, `expect`, `@terreno/test` Mongo preload. **Never mock `@terreno/api` or models.**
290
540
  - Fake TraceSink / ScoreSink in unit tests (in-memory).
291
- - `AIService` tests: emit called; `skipTrace`; sink throw does not fail generate; `AIRequest` still written.
292
- - Config tests: illegal experiment/dataset mix; langfuse reviewQueue; local-off + prompt primary local fails.
293
- - Local experiment: POST creates `BackgroundTask`, items scored, aggregates.
294
- - Langfuse adapter tests: mock `getLangfuseClient` (existing pattern in `langfuseClient.test.ts`).
295
- - Admin widget tests: render list empty/loading (existing admin-frontend patterns).
296
- - `verify-ui-changes` on admin-frontend screens before Brew.
541
+ - `AIService`: emit called; `skipTrace`; a throwing sink does not fail generate; `AIRequest` still written; cost undefined for unpriced models.
542
+ - Config: illegal experiment/dataset mix; langfuse review queue; missing plugin for a primary.
543
+ - Prompts: v1 unchanged after v2; label move; `promptName` resolve uses the production body; missing label → 400.
544
+ - Review: enqueue from trace, submit writes dimension-keyed scores, skip, assign, position/counts, empty state.
545
+ - Evaluators (P2): judge output schema missing a required dimension is rejected on save with that key named; human evaluator with non-zero `liveSampleRate` rejected.
546
+ - Experiments (P2): 1-item dataset still uses `BackgroundTask`; gate pass/fail; promote 409 while a gate fails; unproofread items excluded unless opted in.
547
+ - Langfuse/OTel (P3): mock client and mock exporter; secondary failure never surfaces to the caller.
548
+ - `verify-ui-changes` on the admin screens each phase, before Brew.
297
549
 
298
550
  ## Documentation (same slices as code)
299
551
 
300
552
  | Page | Kind |
301
553
  | --- | --- |
302
554
  | `docs/explanation/ai-observability.md` | Why two planes, plugins, vs `LangfuseApp` / `AIRequest` |
303
- | `docs/how-to/observe-llm-calls.md` | Register plugins, primaries, price map, sampleRate, Open in Langfuse |
304
- | `docs/reference/ai.md` | Models, routes, `AIService` trace options, env |
305
- | `terreno-langfuse-integration.md` | Pointer: native path is this IP; LangfuseApp remains the vendor plugin |
306
- | `ai-prompt-governance` skill | Prompt bodies for judges still named constants **or** `PromptRegistry` production label — not inline strings in `AIService` |
555
+ | `docs/how-to/observe-llm-calls.md` | Register the plugin, price map, sampling ceiling, primaries, Open in Langfuse |
556
+ | `docs/how-to/ai-feature-development.md` | SOP steps mapped to Terreno screens + APIs (operator source of truth) |
557
+ | `docs/reference/ai.md` | Models, routes, `AIService` `promptName`/`promptLabel`/`skipTrace`/`sensitive`, env |
558
+ | `terreno-langfuse-integration.md` | Pointer: the native path is this IP; `LangfuseApp` remains the vendor plugin |
559
+ | `ai-prompt-governance` skill | Runtime prompts come from the registry's production label; judge bodies are registry prompts or named constants — never inline |
307
560
 
308
561
  ## Phases
309
562
 
310
- 1. **Contracts + ObservabilityApp + AIService emit + in-memory sinks** (tracer).
311
- 2. **Local Mongo plugin** (prompts, traces, scores, costs, users/sessions).
312
- 3. **Evaluators + live sampleRate + review queue**.
313
- 4. **Datasets + local experiments via BackgroundTask**.
314
- 5. **Langfuse adapter** (trace/score/prompt/dataset/experiment) + OTel TraceSink + admin UI + example-backend + docs.
563
+ ### Phase 1 — MVP: prompt library, tracing, review queue
564
+
565
+ Contracts, `ObservabilityApp`, `AIService` trace emit and production-label resolution, the local Mongo plugin
566
+ for prompts / traces / spans / scores / human evaluators / review items, the phase-1 routes, and the seven
567
+ phase-1 admin screens. Example-backend registers the plugin and seeds one prompt plus the human evaluator.
568
+
569
+ Exit: an operator can create a prompt, save v2, pin production, watch real traced calls with per-span cost,
570
+ send a weak one to the review queue, and record a clinician's dimension scores against it — with no Langfuse
571
+ keys anywhere.
572
+
573
+ ### Phase 2 — Evaluation loop: evaluators, datasets, experiments
574
+
575
+ llm-judge and json-assert evaluator types with the dimension/schema contract and per-evaluator run modes; live
576
+ sampling execution; datasets with provenance, import, add-from-trace, and synthetic generation; the experiment
577
+ wizard, `BackgroundTask` runner, gates, outliers, side-by-side results, and gate-blocked promote; authenticated
578
+ in-app trace feedback. Eight more admin screens.
579
+
580
+ Exit: the full SOP loop runs end to end in Terreno — gather → label → prompt → evaluators → experiment →
581
+ promote → observe → add deficient traces back.
582
+
583
+ ### Phase 3 — Analytics and pluggable backends
584
+
585
+ Sessions, Users, and Costs screens over the trace store; the Langfuse adapter (trace, score, prompt, dataset,
586
+ experiment) with the four primary flags and **Open in Langfuse**; the OpenInference `OtelTraceSink`; sink
587
+ health in the status chip.
588
+
589
+ Exit: an existing Langfuse app can register both plugins, keep Langfuse as an extra sink or flip individual
590
+ primaries to it, and a third-party collector receives OpenInference spans.
591
+
592
+ ### Phase 4 — Domain-neutral access and prompt relationships
593
+
594
+ Add the six observability RBAC resources, route-level action enforcement, caller-filtered admin
595
+ navigation, compatibility grants for existing Admin/Super Admin/admin-flag users, prompt
596
+ descriptions, version-aware related traces, prompt-filtered experiments, and the read-only prompt
597
+ hub. Approval workflows and domain-specific folder semantics remain outside Terreno.
598
+
599
+ Exit: a consumer can compose a read-only observability role, a reviewer role, or an operator role;
600
+ each user sees only authorized screens/actions, and opening a prompt explains its purpose and shows
601
+ the traces and experiments that use it.
315
602
 
316
603
  ## Acceptance criteria
317
604
 
318
- - [ ] `AIService.generateText` with ObservabilityApp registered writes a nested-capable trace to **every** TraceSink; a throwing sink does **not** fail the generate; `AIRequest` still exists.
319
- - [ ] Creating prompt v2 does not mutate v1; moving `production` is an explicit label API; `get({name, label: "production"})` returns v2.
320
- - [ ] Trace list filters by `userId`, `sessionId`, prompt name/version; detail shows cost USD when price map has the model.
321
- - [ ] Evaluator with two dimensions (numeric + boolean) can score a trace via LLM-judge (`AIService` + named judge prompt) and via human review queue.
322
- - [ ] Dataset of N prior traces; experiment of production vs draft always uses `BackgroundTask` when local; promote sets production label.
323
- - [ ] Langfuse plugin on: same generate dual-writes traces/scores; prompts stay on configured primary; boot fails if `experiments.primary !== datasets.primary` or `reviewQueue` is langfuse.
324
- - [ ] OTel sink: one test asserts an OTLP payload / exporter mock received an OpenInference `LLM` span.
325
- - [ ] Admin: prompts, traces, costs, review, experiments usable as admin; Review hidden without local plugin.
326
- - [ ] Docs pages above match shipped behavior; `bun run --filter=@terreno/ai compile test lint` green.
605
+ ### Phase 1
606
+
607
+ - [ ] `AIService.generateText` with `ObservabilityApp` registered writes a nested-capable trace to every `TraceSink`; a throwing sink does not fail the generate; `AIRequest` still exists.
608
+ - [ ] Creating prompt v2 does not mutate v1; the editor offers no in-place save; moving `production` is an explicit labelled action that names the outgoing version.
609
+ - [ ] `generateText({promptName, promptLabel: "production"})` uses the labelled body; a missing label fails with 400 and no model call.
610
+ - [ ] Prompt list groups by folder with counts and shows calls 7d / cost 7d rolled up from traces; a prompt with no production label shows `—`.
611
+ - [ ] Playground compiles and runs one call, shows compiled messages plus latency / tokens / cost, and creates no version.
612
+ - [ ] Trace list filters by time, prompt, status, user, session, and has-score; a failed trace shows a one-line human cause; a multi-prompt trace shows `N prompts`; cost is blank (not `$0`) for models missing from the price map.
613
+ - [ ] Trace detail renders the span tree with OpenInference kinds and per-span cost; `sensitive` I/O is collapsed by default and badged.
614
+ - [ ] Selecting traces and choosing **Send to review queue** enqueues them; the queue lists pending oldest-first with per-status counts.
615
+ - [ ] The review item screen renders given/wrote panels from prompt-version metadata, generates the score form from the evaluator's dimensions (numeric / boolean / categorical), and **Submit & next** advances, writes dimension-keyed scores, and decrements the pending count to an explicit empty state.
616
+ - [ ] Review queue and its nav entry are hidden when the local plugin is off.
617
+ - [ ] Docs for phase 1 match shipped behavior; `bun run --filter=@terreno/ai compile test lint` green; `verify-ui-changes` evidence attached.
618
+
619
+ ### Phase 2
620
+
621
+ - [ ] An `llm-judge` evaluator whose judge prompt output schema omits a required dimension is rejected on save with that dimension named; a `human` evaluator with `liveSampleRate > 0` is rejected.
622
+ - [ ] Per-evaluator `liveSampleRate` drives live evals, capped by `AI_OBS_SAMPLE_RATE`; default 0 produces no judge calls.
623
+ - [ ] Dataset items distinguish human-annotated from auto-captured, carry attribution, and the Needs-review tab counts unproofread items; adding a `sensitive` trace always lands as needs-review.
624
+ - [ ] Import JSON and add-from-trace (single and bulk) validate against the bound prompt's `inputSchema` and retain `sourceTraceId`.
625
+ - [ ] An experiment compares 2–3 versions on a dataset, always via `BackgroundTask` locally (even for one item), reports gate pass/fail plus outliers and low-confidence items, and floats failed items to the top of the side-by-side table.
626
+ - [ ] Promote returns 409 and renders disabled while any gate fails; with gates passing it moves the production label after an explicit confirm.
627
+ - [ ] Owner `POST feedback` (thumbs down + flagDataset) writes a `user-feedback` score and sets `flaggedForDataset`; a non-owner non-admin gets 403.
628
+ - [ ] The wizard's pre-run estimate reports generation count and USD before the run starts.
629
+
630
+ ### Phase 3
631
+
632
+ - [ ] With both plugins registered, one generate dual-writes traces and scores; prompts stay on the configured primary; boot fails if `experiments.primary !== datasets.primary` or the review queue is `langfuse`.
633
+ - [ ] The OTel sink's mock exporter receives an OpenInference `LLM` span carrying model and token attributes.
634
+ - [ ] Costs shows `tokens only` for unpriced models; Users shows masked identifiers and no prompt content; Sessions renders a timeline.
635
+ - [ ] The status chip reflects local state, Langfuse sink health, and active primaries, and **Open in Langfuse** appears only when the Langfuse plugin is registered.
636
+
637
+ ### Phase 4
638
+
639
+ - [ ] The RBAC catalog exposes all six observability resources and their exact actions; Auditor gets only list/read, Super Admin gets all, and the seeded Admin role receives full observability access without deleting consumer-defined permissions.
640
+ - [ ] Every observability HTTP route enforces its resource/action when access control is configured; legacy `admin: true` callers retain full access when RBAC is absent or the compatibility fallback applies.
641
+ - [ ] `/admin/config` omits unauthorized observability screens. Read-only users can open granted screens but cannot see or invoke denied create, update, playground, scoring, assignment, or promotion actions.
642
+ - [ ] Prompts store and return an optional description without changing folder semantics.
643
+ - [ ] Prompt detail returns and renders related traces and experiments. Trace relationships can be filtered by prompt version; experiment relationships are filtered server-side by prompt name.
644
+ - [ ] Public explanation, operator, AI reference, admin reference, and RBAC reference docs describe the domain-neutral permission and prompt-hub contracts.
327
645
 
328
646
  ## Risks
329
647
 
330
648
  | Risk | Mitigation |
331
649
  | --- | --- |
332
- | Langfuse experiment API vs Terreno dataset | Config equality; no sync job |
333
- | Double OTel SDK vs LangfuseApp | Adapter shares existing tracing init |
334
- | PHI in Mongo | Document; retention is a follow-on IP |
335
- | Live evals cost | `sampleRate` default 0 |
336
- | Missing failover IP | Follow current `AIService` / parent-child `AIRequest` |
337
- | Admin SPA drift | Widgets in admin-frontend automatically appear in spa shell |
650
+ | Phase 1 review queue needs evaluator dimensions before evaluators ship | Human evaluator type lands in phase 1; judge and assert types are additive fields, not a rewrite |
651
+ | Prompt-version metadata for the review panels is easy to skip | `variables[].label` / `reviewerNote` / `outputFieldNotes` are optional with raw-key fallback, so a bare prompt still reviews |
652
+ | Langfuse experiment API vs Terreno dataset | Config equality rule; no sync job |
653
+ | Double OTel SDK vs LangfuseApp | Adapter shares the existing tracing init |
654
+ | PHI in Mongo | `sensitive` flag, collapsed I/O, no content on the Users screen; retention is a follow-on IP |
655
+ | Live eval cost | Per-evaluator rate defaults 0 and is capped by a global ceiling; wizard shows cost before running |
656
+ | Admin SPA drift | Widgets in admin-frontend appear in the spa shell automatically |
338
657
 
339
658
  ## Files to create / modify (expected)
340
659
 
341
- - `ai/src/observability/**` (types, app, local models/stores, otel sink, langfuse adapter)
342
- - `ai/src/service/aiService.ts` — emit traces
343
- - `ai/src/routes/gpt.ts` — pass user/session
344
- - `ai/src/aiAdminApp.ts` / new `observabilityAdmin.ts` — screens
660
+ - `ai/src/observability/**` — types, `observabilityApp.ts`, `local/` models + stores, `evaluate.ts`, `experimentRunner.ts`, `otel/`, `langfuse/`
661
+ - `ai/src/service/aiService.ts` — trace emit; resolve `promptName`/`promptLabel`; `sensitive`, `skipTrace`
662
+ - `ai/src/routes/gpt.ts` — pass user / session / sensitive; optional prompt name
663
+ - `ai/src/aiAdminApp.ts` (or new `observabilityAdmin.ts`) — screen contributions
345
664
  - `ai/src/index.ts` — exports
346
- - `admin-frontend/src/widgets/*` — new screens + registry
347
- - `example-backend/src/server.ts` — register ObservabilityApp
348
- - docs listed above
349
- - tests colocated
665
+ - `admin-frontend/src/widgets/aiObservability/*` — the screens above + registry
666
+ - `example-backend/src/server.ts` + seed script + `.env.example`
667
+ - docs listed above; tests colocated