@marinoscar/kvox-eval 0.0.0-bootstrap → 0.1.0-next.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 (245) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/LICENSE +21 -0
  3. package/README.md +166 -0
  4. package/dist/ask/index.d.ts +6 -0
  5. package/dist/ask/index.js +28 -0
  6. package/dist/ask/question-schema.d.ts +121 -0
  7. package/dist/ask/question-schema.js +167 -0
  8. package/dist/ask/report.d.ts +88 -0
  9. package/dist/ask/report.js +170 -0
  10. package/dist/ask/scorer.d.ts +104 -0
  11. package/dist/ask/scorer.js +164 -0
  12. package/dist/ask-run/ask-command.d.ts +62 -0
  13. package/dist/ask-run/ask-command.js +254 -0
  14. package/dist/ask-run/host/app.d.ts +65 -0
  15. package/dist/ask-run/host/app.js +146 -0
  16. package/dist/ask-run/host/db.d.ts +38 -0
  17. package/dist/ask-run/host/db.js +126 -0
  18. package/dist/ask-run/host/ports.d.ts +35 -0
  19. package/dist/ask-run/host/ports.js +113 -0
  20. package/dist/ask-run/index.d.ts +13 -0
  21. package/dist/ask-run/index.js +35 -0
  22. package/dist/ask-run/replay.d.ts +128 -0
  23. package/dist/ask-run/replay.js +212 -0
  24. package/dist/ask-run/run-ask-eval.d.ts +125 -0
  25. package/dist/ask-run/run-ask-eval.js +193 -0
  26. package/dist/ask-run/seed-golden-graph.d.ts +64 -0
  27. package/dist/ask-run/seed-golden-graph.js +495 -0
  28. package/dist/cli/index.d.ts +3 -0
  29. package/dist/cli/index.js +12 -0
  30. package/dist/cli/kg-command.d.ts +66 -0
  31. package/dist/cli/kg-command.js +238 -0
  32. package/dist/cli/main.d.ts +18 -0
  33. package/dist/cli/main.js +69 -0
  34. package/dist/fixtures/domains.d.ts +16 -0
  35. package/dist/fixtures/domains.js +33 -0
  36. package/dist/fixtures/fixture-schema.d.ts +323 -0
  37. package/dist/fixtures/fixture-schema.js +199 -0
  38. package/dist/fixtures/index.d.ts +16 -0
  39. package/dist/fixtures/index.js +40 -0
  40. package/dist/fixtures/load.d.ts +73 -0
  41. package/dist/fixtures/load.js +137 -0
  42. package/dist/fixtures/questions.d.ts +15 -0
  43. package/dist/fixtures/questions.js +26 -0
  44. package/dist/index.d.ts +20 -0
  45. package/dist/index.js +36 -0
  46. package/dist/kg/gold-runner.d.ts +13 -0
  47. package/dist/kg/gold-runner.js +57 -0
  48. package/dist/kg/index.d.ts +17 -0
  49. package/dist/kg/index.js +65 -0
  50. package/dist/kg/internals.d.ts +7 -0
  51. package/dist/kg/internals.js +17 -0
  52. package/dist/kg/prediction-schema.d.ts +245 -0
  53. package/dist/kg/prediction-schema.js +102 -0
  54. package/dist/kg/predictions.d.ts +11 -0
  55. package/dist/kg/predictions.js +49 -0
  56. package/dist/kg/report.d.ts +84 -0
  57. package/dist/kg/report.js +125 -0
  58. package/dist/kg/run-kg-eval.d.ts +77 -0
  59. package/dist/kg/run-kg-eval.js +137 -0
  60. package/dist/kg/runner.d.ts +72 -0
  61. package/dist/kg/runner.js +51 -0
  62. package/dist/kg/runners/extract-resolve-runner.d.ts +40 -0
  63. package/dist/kg/runners/extract-resolve-runner.js +199 -0
  64. package/dist/kg/runners/extract-runner.d.ts +65 -0
  65. package/dist/kg/runners/extract-runner.js +243 -0
  66. package/dist/kg/scorer.d.ts +226 -0
  67. package/dist/kg/scorer.js +463 -0
  68. package/dist/kg/targets.d.ts +17 -0
  69. package/dist/kg/targets.js +16 -0
  70. package/dist/kg/text.d.ts +45 -0
  71. package/dist/kg/text.js +100 -0
  72. package/dist/live/adapters.d.ts +16 -0
  73. package/dist/live/adapters.js +58 -0
  74. package/dist/live/env.d.ts +39 -0
  75. package/dist/live/env.js +56 -0
  76. package/dist/live/index.d.ts +6 -0
  77. package/dist/live/index.js +16 -0
  78. package/dist/live/live-ai.d.ts +104 -0
  79. package/dist/live/live-ai.js +157 -0
  80. package/dist/parity/constants.d.ts +79 -0
  81. package/dist/parity/constants.js +104 -0
  82. package/dist/parity/index.d.ts +20 -0
  83. package/dist/parity/index.js +38 -0
  84. package/dist/parity/internals.d.ts +8 -0
  85. package/dist/parity/internals.js +16 -0
  86. package/dist/parity/scenario.d.ts +45 -0
  87. package/dist/parity/scenario.js +245 -0
  88. package/dist/parity/script.d.ts +44 -0
  89. package/dist/parity/script.js +136 -0
  90. package/dist/parity/snapshot.d.ts +43 -0
  91. package/dist/parity/snapshot.js +71 -0
  92. package/dist/real-data/index.d.ts +2 -0
  93. package/dist/real-data/index.js +12 -0
  94. package/dist/real-data/real-data.d.ts +76 -0
  95. package/dist/real-data/real-data.js +156 -0
  96. package/fixtures/kg-golden/README.md +136 -0
  97. package/fixtures/kg-golden/ask-questions.json +458 -0
  98. package/fixtures/kg-golden/meetings/m01-northwind-pilot-kickoff.json +792 -0
  99. package/fixtures/kg-golden/meetings/m02-halden-migration-planning.json +814 -0
  100. package/fixtures/kg-golden/meetings/m03-larkspur-weekly-sync.json +601 -0
  101. package/fixtures/kg-golden/meetings/m04-corvid-intake-discovery.json +690 -0
  102. package/fixtures/kg-golden/meetings/m05-hosting-vendor-selection.json +631 -0
  103. package/fixtures/kg-golden/meetings/m06-northwind-floor-data-review.json +649 -0
  104. package/fixtures/kg-golden/meetings/m07-gullwing-intro-notes.json +428 -0
  105. package/fixtures/kg-golden/meetings/m08-halden-migration-checkpoint.json +672 -0
  106. package/fixtures/kg-golden/meetings/m09-northwind-platform-org-update.json +711 -0
  107. package/fixtures/kg-golden/meetings/m10-corvid-prototype-review.json +623 -0
  108. package/fixtures/kg-golden/meetings/m11-solvane-grid-dashboard-scoping.json +689 -0
  109. package/fixtures/kg-golden/meetings/m12-northwind-pilot-replan.json +681 -0
  110. package/fixtures/kg-golden/meetings/m13-halden-cutover-go-no-go.json +721 -0
  111. package/fixtures/kg-golden/meetings/m14-gullwing-discovery-readout.json +719 -0
  112. package/fixtures/kg-golden/meetings/m15-halden-handover-ravi-leaving.json +716 -0
  113. package/fixtures/kg-golden/meetings/m16-varden-claims-portal-intro.json +704 -0
  114. package/fixtures/kg-golden/meetings/m17-corvid-usability-debrief.json +572 -0
  115. package/fixtures/kg-golden/meetings/m18-hosting-decision-revisited.json +655 -0
  116. package/fixtures/kg-golden/meetings/m19-solvane-integration-kickoff.json +726 -0
  117. package/fixtures/kg-golden/meetings/m20-northwind-go-live-retro.json +673 -0
  118. package/fixtures/kg-golden/meetings/m21-quarterly-account-review.json +449 -0
  119. package/fixtures/kg-golden/meetings/m22-halden-intro-call-2020.json +558 -0
  120. package/fixtures/kg-golden/meetings/m23-gullwing-model-review.json +702 -0
  121. package/fixtures/kg-golden/meetings/m24-varden-discovery-midpoint.json +643 -0
  122. package/fixtures/kg-golden/meetings/m25-corvid-rollout-planning.json +642 -0
  123. package/fixtures/kg-golden/meetings/m26-larkspur-engineering-prep.json +695 -0
  124. package/fixtures/kg-golden/meetings/m27-solvane-phase-one-demo.json +649 -0
  125. package/fixtures/kg-golden/meetings/m28-northwind-building-5-scoping.json +715 -0
  126. package/fixtures/kg-golden/meetings/m29-halden-southern-cutover-review.json +752 -0
  127. package/fixtures/kg-golden/meetings/m30-varden-portal-build-kickoff.json +697 -0
  128. package/fixtures/kg-golden/meetings/m31-gullwing-rollout-renewal.json +681 -0
  129. package/fixtures/kg-golden/meetings/m32-brambleway-weekly-sync.json +472 -0
  130. package/fixtures/kg-golden/meetings/m33-nina-oliver-one-on-one.json +342 -0
  131. package/fixtures/kg-golden/meetings/m34-coffee-with-dana.json +249 -0
  132. package/fixtures/kg-golden/meetings/m35-brambleway-june-sync.json +553 -0
  133. package/fixtures/kg-golden/meetings/m36-copenhagen-conference-planning.json +296 -0
  134. package/host/generated/client.d.ts +1 -0
  135. package/host/generated/client.js +5 -0
  136. package/host/generated/default.d.ts +1 -0
  137. package/host/generated/default.js +5 -0
  138. package/host/generated/edge.d.ts +1 -0
  139. package/host/generated/edge.js +1658 -0
  140. package/host/generated/index-browser.js +1684 -0
  141. package/host/generated/index.d.ts +192335 -0
  142. package/host/generated/index.js +1658 -0
  143. package/host/generated/package.json +139 -0
  144. package/host/generated/query_compiler_fast_bg.js +2 -0
  145. package/host/generated/query_compiler_fast_bg.wasm +0 -0
  146. package/host/generated/query_compiler_fast_bg.wasm-base64.js +2 -0
  147. package/host/generated/runtime/client.d.ts +3433 -0
  148. package/host/generated/runtime/client.js +110 -0
  149. package/host/generated/runtime/index-browser.d.ts +90 -0
  150. package/host/generated/runtime/index-browser.js +6 -0
  151. package/host/generated/runtime/wasm-compiler-edge.js +100 -0
  152. package/host/generated/schema.prisma +6312 -0
  153. package/host/generated/wasm-edge-light-loader.mjs +5 -0
  154. package/host/generated/wasm-worker-loader.mjs +5 -0
  155. package/host/prisma/fragments/kvox.ask.prisma +126 -0
  156. package/host/prisma/fragments/kvox.graph.prisma +1073 -0
  157. package/host/prisma/fragments/kvox.notes.prisma +787 -0
  158. package/host/prisma/fragments/kvox.search.prisma +211 -0
  159. package/host/prisma/fragments/kvox.transcripts.prisma +790 -0
  160. package/host/prisma/kvox.lock +101 -0
  161. package/host/prisma/migrations/20240101000000_initial/migration.sql +297 -0
  162. package/host/prisma/migrations/20240101000001_add_personal_access_tokens/migration.sql +31 -0
  163. package/host/prisma/migrations/20240101000002_add_credentials/migration.sql +20 -0
  164. package/host/prisma/migrations/20240101000003_add_notification_deliveries/migration.sql +27 -0
  165. package/host/prisma/migrations/20240101000004_drop_stale_uuid_defaults/migration.sql +35 -0
  166. package/host/prisma/migrations/20240101000005_add_notifications/migration.sql +22 -0
  167. package/host/prisma/migrations/20240101000006_add_push_subscriptions/migration.sql +25 -0
  168. package/host/prisma/migrations/20240101000007_add_jobs/migration.sql +110 -0
  169. package/host/prisma/migrations/20240101000008_add_worker_nodes/migration.sql +90 -0
  170. package/host/prisma/migrations/20240101000009_add_database_backup_runs/migration.sql +104 -0
  171. package/host/prisma/migrations/20240101000010_add_notification_broadcasts/migration.sql +57 -0
  172. package/host/prisma/migrations/20240101000011_add_backup_run_job_link/migration.sql +87 -0
  173. package/host/prisma/migrations/20240101000012_add_job_node_secrets/migration.sql +61 -0
  174. package/host/prisma/migrations/20240101000013_add_backup_run_pg_dump_version/migration.sql +26 -0
  175. package/host/prisma/migrations/20240101000014_add_job_claim_token/migration.sql +92 -0
  176. package/host/prisma/migrations/20240101000015_add_ai_platform/migration.sql +108 -0
  177. package/host/prisma/migrations/20240101000016_revoke_viewer_ai_use/migration.sql +32 -0
  178. package/host/prisma/migrations/20240101000017_add_user_credentials/migration.sql +29 -0
  179. package/host/prisma/migrations/20240101000018_add_device_session_credential_link/migration.sql +52 -0
  180. package/host/prisma/migrations/20240101000019_add_worker_node_vitals/migration.sql +3 -0
  181. package/host/prisma/migrations/20240101000020_add_job_trace_context/migration.sql +6 -0
  182. package/host/prisma/migrations/20240101000021_add_retention_created_at_indexes/migration.sql +13 -0
  183. package/host/prisma/migrations/20240101000022_add_organizations/migration.sql +142 -0
  184. package/host/prisma/migrations/20240101000023_split_system_org_roles/migration.sql +131 -0
  185. package/host/prisma/migrations/20240101000024_org_scoped_rls/migration.sql +215 -0
  186. package/host/prisma/migrations/20240101000025_add_groups/migration.sql +165 -0
  187. package/host/prisma/migrations/20240101000026_add_grants/migration.sql +117 -0
  188. package/host/prisma/migrations/20240101000027_add_org_settings/migration.sql +49 -0
  189. package/host/prisma/migrations/20240101000028_add_org_credentials/migration.sql +54 -0
  190. package/host/prisma/migrations/20240101000029_add_jobs_org_id/migration.sql +18 -0
  191. package/host/prisma/migrations/20240101000030_add_jobs_org_id_status_index/migration.sql +6 -0
  192. package/host/prisma/migrations/20240101000031_add_broadcast_target_org/migration.sql +21 -0
  193. package/host/prisma/migrations/20240101000032_add_android_app/migration.sql +60 -0
  194. package/host/prisma/migrations/20240101000033_open_job_reason/migration.sql +16 -0
  195. package/host/prisma/migrations/20240101000034_ai_usage_feature/migration.sql +37 -0
  196. package/host/prisma/migrations/20240101000035_add_app_metadata/migration.sql +49 -0
  197. package/host/prisma/migrations/20240101000036_open_sharing_vocabularies/migration.sql +97 -0
  198. package/host/prisma/migrations/20260914130000_kvox_add_transcripts/migration.sql +322 -0
  199. package/host/prisma/migrations/20260914140000_kvox_add_notes/migration.sql +311 -0
  200. package/host/prisma/migrations/20260915120000_kvox_add_search_vectors/migration.sql +150 -0
  201. package/host/prisma/migrations/20260915130000_kvox_add_search_embeddings/migration.sql +362 -0
  202. package/host/prisma/migrations/20260915210000_kvox_add_note_title_source/migration.sql +30 -0
  203. package/host/prisma/migrations/20260923120000_kvox_add_note_generation_context_snapshot/migration.sql +24 -0
  204. package/host/prisma/migrations/20260923130000_kvox_add_user_hidden_note_templates/migration.sql +35 -0
  205. package/host/prisma/migrations/20260923140000_kvox_add_transcript_speaker_identities/migration.sql +6 -0
  206. package/host/prisma/migrations/20260923150000_kvox_transcript_name_checks/migration.sql +95 -0
  207. package/host/prisma/migrations/20260923160000_kvox_add_note_body_format/migration.sql +27 -0
  208. package/host/prisma/migrations/20260923170000_kvox_add_note_version_body_format/migration.sql +23 -0
  209. package/host/prisma/migrations/20260926012548_kvox_add_knowledge_graph/migration.sql +680 -0
  210. package/host/prisma/migrations/20260926030000_kvox_fix_kg_relations_valid_precision_chk/migration.sql +29 -0
  211. package/host/prisma/migrations/20260926030100_kvox_add_transcript_recorded_at/migration.sql +9 -0
  212. package/host/prisma/migrations/20260926030200_kvox_kg_evidence_invariant/migration.sql +60 -0
  213. package/host/prisma/migrations/20260926040000_kvox_add_kg_graph_layouts/migration.sql +30 -0
  214. package/host/prisma/migrations/20260926050000_kvox_add_ask_conversations/migration.sql +99 -0
  215. package/host/prisma/migrations/20260926231512_kvox_add_kg_exports/migration.sql +52 -0
  216. package/host/prisma/migrations/20261016120000_kvox_transcripts_org_id/migration.sql +49 -0
  217. package/host/prisma/platform.lock +319 -0
  218. package/host/prisma/schema/app.kvox.ask.prisma +110 -0
  219. package/host/prisma/schema/app.kvox.graph.prisma +1030 -0
  220. package/host/prisma/schema/app.kvox.notes.prisma +746 -0
  221. package/host/prisma/schema/app.kvox.search.prisma +208 -0
  222. package/host/prisma/schema/app.kvox.transcripts.prisma +726 -0
  223. package/host/prisma/schema/platform.ai.prisma +419 -0
  224. package/host/prisma/schema/platform.android-app.prisma +46 -0
  225. package/host/prisma/schema/platform.base.prisma +15 -0
  226. package/host/prisma/schema/platform.credentials.prisma +156 -0
  227. package/host/prisma/schema/platform.db-backup.prisma +359 -0
  228. package/host/prisma/schema/platform.identity.prisma +638 -0
  229. package/host/prisma/schema/platform.jobs.prisma +824 -0
  230. package/host/prisma/schema/platform.notifications.prisma +641 -0
  231. package/host/prisma/schema/platform.settings.prisma +53 -0
  232. package/host/prisma/schema/platform.sharing.prisma +208 -0
  233. package/host/prisma/schema/platform.storage.prisma +118 -0
  234. package/package.json +112 -1
  235. package/parity/requests.ask.json +2681 -0
  236. package/parity/requests.m01.json +1554 -0
  237. package/parity/requests.m02.json +1554 -0
  238. package/parity/requests.m03.json +1554 -0
  239. package/parity/requests.m04.json +1554 -0
  240. package/parity/requests.m05.json +1554 -0
  241. package/parity/requests.m06.json +1554 -0
  242. package/parity/requests.m07.json +1554 -0
  243. package/parity/requests.m08.json +1554 -0
  244. package/parity/requests.m09.json +1554 -0
  245. package/parity/requests.m10.json +1554 -0
@@ -0,0 +1,419 @@
1
+ // GENERATED by platform db compose — do not edit
2
+ // source: package:ai.prisma
3
+
4
+ // =============================================================================
5
+ // AI Platform (issue #423, epic #419, umbrella #418)
6
+ // =============================================================================
7
+ //
8
+ // Four tables — the model catalog, per-user encrypted provider keys,
9
+ // background AI run results, and usage accounting — for the whole platform
10
+ // this epic builds. THIS ISSUE OWNS SCHEMA ONLY: no service or controller in
11
+ // this build reads or writes any of these tables yet (that is #427, #428,
12
+ // #431, #432). They land ahead of their consumers, on the same "declare the
13
+ // collision hotspot once, let every other AI story run in parallel" logic
14
+ // `system-settings.service.ts`'s operations-namespaces header already argues
15
+ // for `system_settings`: `schema.prisma`, `seed-data.ts`,
16
+ // `roles.constants.ts` and `settings.schema.ts` are each touched by every
17
+ // story in the epic, so this one story claims them in a single migration
18
+ // instead of every later story racing to edit the same four files.
19
+ //
20
+ // -----------------------------------------------------------------------------
21
+ // ⚠ NO COLUMN HERE CAN EVER HOLD A PLAINTEXT PROVIDER KEY
22
+ // -----------------------------------------------------------------------------
23
+ // `UserAiKey.secret` is ciphertext ONLY — the output of `encryptSecret(
24
+ // plaintext, 'ai_user_key')` (`@marinoscar/platform-api/core`), the same
25
+ // AES-GCM helper `Credential.secret` already uses, keyed under a purpose
26
+ // label specific to this table so a ciphertext lifted from one row cannot be
27
+ // decrypted as if it were the other's. `hint` is the non-secret display aid
28
+ // (`'••••' + last 4`, mirroring `Credential.hint`), never enough on its own
29
+ // to reconstruct the key. `AiRun.request` is explicitly a NORMALIZED request
30
+ // — model id, parameters, truncated/redacted prompt shape — never the
31
+ // caller's raw provider key, which the run's own executor already holds
32
+ // separately (from `UserAiKey`, decrypted for that one call and discarded).
33
+ //
34
+ // -----------------------------------------------------------------------------
35
+ // `UserAiKey` — Cascade, NOT SetNull, and the contrast with `Credential` is
36
+ // deliberate
37
+ // -----------------------------------------------------------------------------
38
+ // `Credential.updatedByUser` is `SetNull`: that table is shared
39
+ // infrastructure (an SMTP password, a storage secret) that outlives whoever
40
+ // last edited it, because the credential belongs to the DEPLOYMENT, not to
41
+ // the editor. `UserAiKey.user` is the opposite shape on purpose — a BYOK key
42
+ // belongs to the ONE ACCOUNT that typed it in, has no meaning to anyone else,
43
+ // and (like `PushSubscription.user`, which makes the identical argument for a
44
+ // live per-device credential) has no reader once that account is gone. There
45
+ // is no "the deployment's OpenAI key" here, only "this user's OpenAI key", so
46
+ // deleting the user is what should delete it.
47
+ //
48
+ // -----------------------------------------------------------------------------
49
+ // `AiRun.user` / `AiUsageEvent.user` — SetNull, matching `NotificationDelivery
50
+ // .user`
51
+ // -----------------------------------------------------------------------------
52
+ // Both are HISTORY, not live state: a run's output and a usage event's token
53
+ // counts remain meaningful — for billing reconciliation, for "was this
54
+ // account affected by the incident on provider X" — after the account that
55
+ // triggered them is gone, exactly the argument `NotificationDelivery.user`'s
56
+ // own block comment makes for a delivery attempt. Nulling the FK preserves
57
+ // that record; cascading it would erase usage accounting as a side effect of
58
+ // an unrelated account-deletion path elsewhere in the app. Both are also
59
+ // deliberately nullable up front (not merely nullable-after-cascade), the same
60
+ // "global job" shape `Job.subjectType`/`subjectId` use: a background run or a
61
+ // catalog-discovery usage event may have no requesting user at all.
62
+ //
63
+ // -----------------------------------------------------------------------------
64
+ // `capabilities` / `units` — JSONB, not typed columns, and Zod-validated in
65
+ // code
66
+ // -----------------------------------------------------------------------------
67
+ // `AiModel.capabilities` (what a model can do — text, vision, tool calling,
68
+ // structured output, ...) and `AiUsageEvent.units` (a per-operation unit
69
+ // count that varies by kind: `{ images: 2 }`, `{ audioSeconds: 31.4 }`) are
70
+ // each shaped differently per provider and per operation, and neither is
71
+ // filtered or indexed on by any query this issue's schema needs to support.
72
+ // Both follow `Job.payload`'s and `WorkerNode.capabilities`' own precedent:
73
+ // opaque JSONB here, a real Zod schema (`AiModelCapabilities`) at the
74
+ // consuming layer that arrives with #427/#428. A typed column per capability
75
+ // would be a migration every time a provider ships a new one.
76
+ //
77
+ // -----------------------------------------------------------------------------
78
+ // `provider` / `operation` / `status` / `keySource` — plain strings, not
79
+ // Prisma enums
80
+ // -----------------------------------------------------------------------------
81
+ // Same reasoning as `Job.type`, `NotificationDelivery.channel` and
82
+ // `WorkerNode.executor` throughout this file: these are small, code-owned,
83
+ // GROWING sets (`AI_PROVIDER_IDS` names `'openai'` today and Phase 3 appends
84
+ // to it; new operations and key sources are exactly the shape of change a
85
+ // provider integration adds), so a Postgres enum would reintroduce the
86
+ // migration this design otherwise avoids, and a row must be able to outlive
87
+ // whatever build produced it. `AiRun.status` and `AiUsageEvent.status` are
88
+ // each that model's OWN closed state machine in every other sense — but they
89
+ // are kept as strings rather than promoted to a Prisma enum for the same
90
+ // forward-compatibility reason the rest of this section gives, not because
91
+ // they are open-ended in practice.
92
+ //
93
+ // -----------------------------------------------------------------------------
94
+ // `AiModel.capabilitySource` — `'catalog' | 'admin_override' | 'unclassified'`
95
+ // -----------------------------------------------------------------------------
96
+ // Records WHERE `capabilities` came from, because the three sources disagree
97
+ // in trustworthiness and in how long they last: `'catalog'` is the
98
+ // provider's own advertised capability list (refreshed on discovery, and the
99
+ // first thing a later sync overwrites), `'admin_override'` is a deployment
100
+ // operator's manual correction (must survive a catalog refresh that would
101
+ // otherwise clobber it — that survival rule belongs to #427's sync logic, not
102
+ // to this column), and `'unclassified'` is the default for a model this
103
+ // deployment has seen but never classified at all. A model discovered before
104
+ // any classification runs is `'unclassified'`, never a guess.
105
+ model AiModel {
106
+ id String @id @default(uuid()) @db.Uuid
107
+
108
+ // The provider registration this model belongs to, e.g. `'openai'`. See
109
+ // `AI_PROVIDER_IDS` (`common/schemas/settings.schema.ts`) — a growing,
110
+ // code-owned list, and a plain string here for the reason given above.
111
+ provider String
112
+
113
+ // The provider's own raw model identifier (e.g. `'gpt-4o-mini'`), as
114
+ // opposed to `id` above, which is this table's own row identity.
115
+ modelId String @map("model_id")
116
+
117
+ // Operator/UI-facing label. Nullable: a freshly discovered model has none
118
+ // until an admin (or a later sync) names it.
119
+ displayName String? @map("display_name")
120
+
121
+ // AiModelCapabilities, Zod-validated at the consuming layer (#427) — see
122
+ // the block comment above for why this is JSONB rather than typed columns.
123
+ capabilities Json
124
+
125
+ // Where `capabilities` came from — see the block comment above.
126
+ // `'unclassified'` is the default: a model this deployment has observed but
127
+ // not yet classified, one way or the other.
128
+ capabilitySource String @default("unclassified") @map("capability_source")
129
+
130
+ // Whether this deployment offers the model to callers at all. Defaults to
131
+ // `false`: a freshly discovered model is inert until an administrator (or a
132
+ // later sync's own policy) turns it on, the same "nothing changes behaviour
133
+ // on discovery alone" posture `WorkerNode.status` defaulting to `offline`
134
+ // and `databaseBackup.enabled` defaulting to `false` both take elsewhere in
135
+ // this schema.
136
+ enabled Boolean @default(false)
137
+
138
+ contextWindow Int? @map("context_window")
139
+ maxOutputTokens Int? @map("max_output_tokens")
140
+
141
+ // When this row was first created, vs. `lastSeenAt` below, which moves on
142
+ // every discovery sync that still finds the model — the same "creation time
143
+ // vs. liveness" split `WorkerNode.registeredAt`/`.lastHeartbeatAt` make for
144
+ // an entirely different kind of inventory.
145
+ discoveredAt DateTime @default(now()) @map("discovered_at") @db.Timestamptz
146
+ lastSeenAt DateTime @default(now()) @map("last_seen_at") @db.Timestamptz
147
+ // Set by a discovery sync (#427) that no longer sees this model in the
148
+ // provider's own catalog. Nullable: most rows are never deprecated.
149
+ // Deliberately not deleted — a deprecated model may still be referenced by
150
+ // historical `AiRun`/`AiUsageEvent` rows via its `provider`/`modelId` pair,
151
+ // and losing the row would lose the display name and capability record for
152
+ // that history.
153
+ deprecatedAt DateTime? @map("deprecated_at") @db.Timestamptz
154
+
155
+ // Provenance of the last edit — see the block comment above for why this
156
+ // is `SetNull`, matching `Credential.updatedByUser` rather than
157
+ // `UserAiKey.user` below.
158
+ updatedByUserId String? @map("updated_by_user_id") @db.Uuid
159
+ updatedByUser User? @relation("AiModelUpdatedBy", fields: [updatedByUserId], references: [id], onDelete: SetNull)
160
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
161
+
162
+ // One row per (provider, model) pair, ever — a discovery sync upserts on
163
+ // this rather than inserting duplicates on every re-run.
164
+ @@unique([provider, modelId])
165
+ // "Which models may this deployment currently offer" — the catalog read
166
+ // every AI-calling path needs, filtered to the provider in play.
167
+ @@index([provider, enabled])
168
+ @@map("ai_models")
169
+ }
170
+
171
+ // BYOK ("bring your own key"): one encrypted provider key per (user,
172
+ // provider), never per deployment. See the block comment above this section
173
+ // for why `user` is `Cascade` here and NOT `SetNull` like `Credential
174
+ // .updatedByUser` or `AiModel.updatedByUser` above — this key has exactly one
175
+ // owner and no meaning to anyone else once that owner's account is gone.
176
+ model UserAiKey {
177
+ id String @id @default(uuid()) @db.Uuid
178
+ userId String @map("user_id") @db.Uuid
179
+
180
+ // Which provider this key authenticates against, e.g. `'openai'`. Plain
181
+ // string for the same forward-compatibility reason `AiModel.provider` is.
182
+ provider String
183
+
184
+ // Ciphertext ONLY — `encryptSecret(plaintext, 'ai_user_key')`. See the
185
+ // block comment above this section (and `Credential.secret`'s own comment)
186
+ // for the full argument; @db.Text for the identical reason
187
+ // `Credential.secret` is: an AES-GCM payload has no meaningful length
188
+ // bound, so it must never be truncated.
189
+ secret String @db.Text
190
+
191
+ // Non-secret display aid (`'••••' + last 4`), mirroring `Credential.hint`.
192
+ // Nullable: a key can exist before anyone has decided how to hint it.
193
+ hint String?
194
+
195
+ // The last time this key was proven to work against its provider (a live
196
+ // probe call, not merely "was saved"). Nullable: never verified yet.
197
+ verifiedAt DateTime? @map("verified_at") @db.Timestamptz
198
+ // The provider's own error code from the most recent failed verification or
199
+ // use, so a user who cannot see why a key stopped working has somewhere to
200
+ // look. Nullable: no failure recorded (yet, or ever).
201
+ lastErrorCode String? @map("last_error_code")
202
+
203
+ // Which of this provider's models this specific key was actually able to
204
+ // reach on its last verification, as opposed to `AiModel.enabled`, which is
205
+ // a DEPLOYMENT-WIDE policy decision independent of any one key. A key with
206
+ // a narrower API scope, or one hitting a provider-side allowlist, can
207
+ // legitimately reach fewer models than the deployment otherwise offers.
208
+ reachableModelIds String[] @default([]) @map("reachable_model_ids")
209
+ reachableCheckedAt DateTime? @map("reachable_checked_at") @db.Timestamptz
210
+
211
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
212
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
213
+
214
+ // Relations
215
+ //
216
+ // Cascade — see the block comment above this section for why this
217
+ // deliberately reverses `Credential.updatedByUser`'s `SetNull`.
218
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
219
+
220
+ // One key per (user, provider) — saving a new key for a provider replaces
221
+ // the old one rather than accumulating a history of them.
222
+ @@unique([userId, provider])
223
+ @@map("user_ai_keys")
224
+ }
225
+
226
+ // One row per AI call this platform ever ran through the background queue —
227
+ // the record `#432`'s job handler(s) read from and write progress/output to.
228
+ // This issue is schema only, matching every other table in this section: no
229
+ // enqueue path, no job handler and no controller exists yet.
230
+ // @extensible — back-relations only, added with "extend model AiRun { ... }" in a fragment
231
+ // (an app's own annotation of a run, for example a review verdict). The table is org-owned
232
+ // with row-level security forced, so a table of the app's that holds `org_id` links to a run
233
+ // with a composite foreign key `(ai_run_id, org_id)` -> `ai_runs (id, org_id)`; the
234
+ // `@@unique([id, orgId])` below exists for it.
235
+ model AiRun {
236
+ id String @id @default(uuid()) @db.Uuid
237
+
238
+ // Nullable, deliberately — see the block comment above this section for why
239
+ // this is `SetNull` rather than `Cascade`, and why a run may have no
240
+ // requesting user at all (a system-initiated run, mirroring `Job.subjectType`
241
+ // /`subjectId` both being null together for a global job).
242
+ userId String? @map("user_id") @db.Uuid
243
+
244
+ // The `jobs` row this run executes under, when it runs through the queue at
245
+ // all. Nullable, and deliberately NOT a foreign key — mirrors
246
+ // `DatabaseBackupRun`'s OWN relation to `Job` in spirit (a dedicated table
247
+ // outliving the generic queue's retention) but stays a plain column rather
248
+ // than a `@relation`+`@unique` pair: unlike a backup run, an `AiRun` is not
249
+ // one-to-one with its job in every future shape this platform may take
250
+ // (a retried run may reuse the id, or a future synchronous path may have no
251
+ // job at all), so this issue commits only to recording the id, not to the
252
+ // stronger one-to-one relation `DatabaseBackupRun.jobId` commits to.
253
+ jobId String? @map("job_id") @db.Uuid
254
+
255
+ // pending|running|succeeded|failed|cancelled — this run's own state
256
+ // machine, kept as a plain string rather than a Prisma enum for the
257
+ // forward-compatibility reason given in the block comment above this
258
+ // section.
259
+ status String @default("pending")
260
+
261
+ provider String
262
+ modelId String @map("model_id")
263
+
264
+ // The NORMALIZED request — model id, parameters, and the FULL prompt shape
265
+ // (instructions, input, tools, structured-output schema) `toStoredRunRequest`
266
+ // (`runtime/ai-run-request.ts`) built from it — and NEVER the caller's key
267
+ // material, which this run's executor holds separately and only for the
268
+ // duration of the call. Deliberately not redacted or truncated: the
269
+ // worker that claims this job re-issues the user's exact request, and a
270
+ // truncated prompt would make the replayed call diverge from the one the
271
+ // user actually asked for. See the ⚠ block comment above this section.
272
+ request Json
273
+
274
+ output Json?
275
+ errorCode String? @map("error_code")
276
+ errorMessage String? @map("error_message")
277
+
278
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
279
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
280
+ completedAt DateTime? @map("completed_at") @db.Timestamptz
281
+
282
+ // The organization the run belongs to (#725). NOT NULL, row-level security
283
+ // forced (policy `ai_runs_org_isolation`). `Restrict`, not `SetNull`: the
284
+ // column is NOT NULL, so history cannot survive an organization's deletion
285
+ // by nulling it; deleting an organization first purges its runs.
286
+ orgId String @map("org_id") @db.Uuid
287
+
288
+ // The registered AI feature the run was started for (`registerAiFeature`,
289
+ // PP-14.17, #935): a bounded id the feature registry validated, NEVER free
290
+ // text. Nullable (a run started without a feature, and every row written
291
+ // before this column existed), and a plain string rather than a reference:
292
+ // a feature is code, not data, and a row must outlive the feature that
293
+ // wrote it. Carried onto the run's usage rows, so per-feature usage is
294
+ // answerable from the database.
295
+ feature String?
296
+
297
+ // Relations
298
+ //
299
+ // SetNull — see the block comment above this section.
300
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
301
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Restrict)
302
+
303
+ // "This user's run history, most recent first" — the one query this table
304
+ // is known to need today (a user's own run list).
305
+ @@index([userId, createdAt])
306
+ // The same query behind the policy filter `org_id = X`.
307
+ @@index([orgId, userId, createdAt])
308
+ // The retention sweep (`ai.runs.purge`, #681): "oldest first, older than
309
+ // the cutoff" across every user, which `[userId, createdAt]` cannot serve.
310
+ @@index([createdAt])
311
+ // The target of an app table's composite foreign key (id, org_id), like
312
+ // `Group` and `StorageObject`: a plain FK on `id` would let a row of one
313
+ // organization point at another's (an FK check bypasses row-level security).
314
+ @@unique([id, orgId])
315
+ @@map("ai_runs")
316
+ }
317
+
318
+ // One row per AI operation attempted, successful or not — the usage/billing
319
+ // accounting ledger, deliberately separate from `AiRun` above. `AiRun` is
320
+ // "what did this background call do" (one row per queued call, with its own
321
+ // request/output); `AiUsageEvent` is "what did this cost", covering every
322
+ // operation kind (`responses|images|audio.transcribe|audio.speech|
323
+ // embeddings|catalog`) whether or not it went through the queue at all — a
324
+ // synchronous catalog-listing call has no `AiRun` but still consumes a
325
+ // provider call worth recording.
326
+ model AiUsageEvent {
327
+ id String @id @default(uuid()) @db.Uuid
328
+
329
+ // Nullable, `SetNull` — see the block comment above this section. A
330
+ // catalog-discovery sync run by the deployment itself, not by any one
331
+ // user, is the concrete case that leaves this null.
332
+ userId String? @map("user_id") @db.Uuid
333
+
334
+ provider String
335
+ modelId String @map("model_id")
336
+
337
+ // responses|images|audio.transcribe|audio.speech|embeddings|catalog — the
338
+ // kind of provider call this event records. Plain string for the
339
+ // forward-compatibility reason given above: a new operation kind (a future
340
+ // provider capability) must cost zero migrations here.
341
+ operation String
342
+
343
+ // user|org|admin_discovery — WHOSE key paid for this call. Distinct from
344
+ // `provider`/`modelId` (which model answered) and from `userId` (who asked):
345
+ // an admin-triggered catalog discovery sync can be `keySource:
346
+ // 'admin_discovery'` with no requesting `userId` at all, and a BYOK-only
347
+ // deployment's every real usage event is `keySource: 'user'`. `'org'` is
348
+ // reserved for a future org-wide fallback key, matching `AI_KEY_POLICIES`'
349
+ // `'byok_with_org_fallback'` in `common/schemas/settings.schema.ts`.
350
+ keySource String @map("key_source")
351
+
352
+ // Token accounting. All nullable: an `images`/`audio.*` operation has no
353
+ // token counts at all, and `reasoningTokens`/`cachedInputTokens` are
354
+ // provider-specific breakdowns that not every provider or model reports.
355
+ inputTokens Int? @map("input_tokens")
356
+ outputTokens Int? @map("output_tokens")
357
+ reasoningTokens Int? @map("reasoning_tokens")
358
+ cachedInputTokens Int? @map("cached_input_tokens")
359
+
360
+ // Non-token unit accounting for operations tokens don't measure — e.g.
361
+ // `{ images: 2 }` for an image generation call, `{ audioSeconds: 31.4 }` for
362
+ // transcription/speech. JSONB for the reason given in the block comment
363
+ // above this section: the shape varies by `operation` and no query here
364
+ // filters or indexes on it.
365
+ units Json?
366
+
367
+ latencyMs Int @map("latency_ms")
368
+
369
+ // succeeded|failed|cancelled — this event's own closed outcome, kept as a
370
+ // plain string for the identical forward-compatibility reason `AiRun
371
+ // .status` is.
372
+ status String
373
+ errorCode String? @map("error_code")
374
+
375
+ // The provider's own request/trace id, for reconciling a usage row against
376
+ // that provider's dashboard or support ticket. Nullable: not every provider
377
+ // call surfaces one.
378
+ providerRequestId String? @map("provider_request_id")
379
+
380
+ // The `jobs` row this usage event was incurred under, when it was. Plain
381
+ // column, no relation — same reasoning as `AiRun.jobId` above.
382
+ jobId String? @map("job_id") @db.Uuid
383
+
384
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
385
+
386
+ // The organization that incurred the call (#725). Nullable on purpose: a
387
+ // deployment-wide event (a catalogue sync) has none, and `SetNull` keeps the
388
+ // history of a deleted organization. Row-level security forced (policy
389
+ // `ai_usage_events_org_isolation`): a NULL row is visible to the system
390
+ // client only.
391
+ orgId String? @map("org_id") @db.Uuid
392
+
393
+ // The registered AI feature the call was made for (`registerAiFeature`,
394
+ // PP-14.17, #935), so usage can be filtered and grouped per feature from
395
+ // the database. Nullable: a call made without a feature, a catalogue sync,
396
+ // and every row written before this column existed have none. A plain
397
+ // string, never a reference, and never free text: only an id the feature
398
+ // registry validated is written.
399
+ feature String?
400
+
401
+ // Relations
402
+ user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
403
+ org Organization? @relation(fields: [orgId], references: [id], onDelete: SetNull)
404
+
405
+ // "This user's usage history, most recent first" — mirrors `AiRun`'s own
406
+ // index for the identical reason.
407
+ @@index([userId, createdAt])
408
+ // The same query behind the policy filter `org_id = X`, and the per-org
409
+ // usage window.
410
+ @@index([orgId, userId, createdAt])
411
+ @@index([orgId, createdAt])
412
+ // "This organization's usage of one feature in a window" (PP-14.17, #935):
413
+ // the per-feature filter and group-by of the usage report.
414
+ @@index([orgId, feature, createdAt])
415
+ // "Usage across all users in a time window" — the deployment-wide
416
+ // accounting/billing query, independent of any one user.
417
+ @@index([createdAt])
418
+ @@map("ai_usage_events")
419
+ }
@@ -0,0 +1,46 @@
1
+ // GENERATED by platform db compose — do not edit
2
+ // source: package:android-app.prisma
3
+
4
+ /// An Android APK this deployment hosts (#746, PP-9.4; merged from EvoPath and
5
+ /// MemoriaHub). Uploaded by an administrator (the admin Android app page, or
6
+ /// `appctl android publish`), streamed into object storage under the
7
+ /// `android-releases/` key prefix, downloaded by signed-in users through a
8
+ /// short-lived signed link (`/api/android-app/download/<token>`).
9
+ ///
10
+ /// AT MOST ONE RELEASE IS CURRENT deployment-wide: the raw-SQL partial unique
11
+ /// index `android_app_releases_one_current_uniq_idx` (migration SQL only;
12
+ /// Prisma cannot express a partial unique index). Never declare it as
13
+ /// @@unique and never replace it with a findFirst pre-check: make-current
14
+ /// clears the old flag and sets the new one in one transaction, and the
15
+ /// database rejects a second current row (P2002, mapped to 409
16
+ /// RELEASE_CURRENT_CONFLICT).
17
+ ///
18
+ /// `sizeBytes` is BigInt (MemoriaHub's choice; EvoPath used Int): the API
19
+ /// returns it as a decimal string. `storageProvider` and `bucket` record where
20
+ /// the bytes were written (the DatabaseBackupRun precedent), so a doctor check
21
+ /// can tell a release stranded by a provider switch. A deployment artifact:
22
+ /// kept by the user-data reset (uploader SetNull) and by the factory reset
23
+ /// (its prefix is registered `survivesFactoryReset: true`).
24
+ model AndroidAppRelease {
25
+ id String @id @default(uuid()) @db.Uuid
26
+ packageName String @map("package_name")
27
+ versionName String @map("version_name") @db.VarChar(50)
28
+ versionCode Int @map("version_code")
29
+ signingSha256 String @map("signing_sha256")
30
+ fileSha256 String @map("file_sha256") @db.Char(64)
31
+ sizeBytes BigInt @map("size_bytes")
32
+ storageKey String @map("storage_key")
33
+ storageProvider String? @map("storage_provider")
34
+ bucket String?
35
+ notes String? @db.VarChar(2000)
36
+ isCurrent Boolean @default(false) @map("is_current")
37
+ uploadedById String? @map("uploaded_by_id") @db.Uuid
38
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
39
+
40
+ uploadedBy User? @relation("AndroidAppReleaseUploader", fields: [uploadedById], references: [id], onDelete: SetNull)
41
+
42
+ @@unique([packageName, versionCode])
43
+ @@index([createdAt])
44
+ @@index([uploadedById])
45
+ @@map("android_app_releases")
46
+ }
@@ -0,0 +1,15 @@
1
+ // GENERATED by platform db compose — do not edit
2
+ // source: package:base.prisma
3
+
4
+ // =============================================================================
5
+ // Prisma Schema - Web Application Foundation
6
+ // =============================================================================
7
+
8
+ generator client {
9
+ provider = "prisma-client-js"
10
+ output = "../../generated"
11
+ }
12
+
13
+ datasource db {
14
+ provider = "postgresql"
15
+ }
@@ -0,0 +1,156 @@
1
+ // GENERATED by platform db compose — do not edit
2
+ // source: package:credentials.prisma
3
+
4
+ // =============================================================================
5
+ // Encrypted Credential Store (issue #115, epic #108)
6
+ // =============================================================================
7
+ //
8
+ // Runtime-configured secrets (an SMTP password, an OAuth client secret, a
9
+ // second bucket's S3 key, ...) addressed by (purpose, name) rather than one
10
+ // column per consumer, so every future secret reuses this table instead of
11
+ // inventing its own column, its own encrypt call, and its own chance to leak.
12
+ //
13
+ // `secret` holds ONLY the opaque base64 ciphertext produced by
14
+ // `encryptSecret()` from `@marinoscar/platform-api/core`, encrypted under
15
+ // `purpose` as the sub-key derivation label — a ciphertext lifted from one
16
+ // row cannot be decrypted under a different purpose. This model stores
17
+ // ciphertext and non-secret metadata only; it never sees plaintext, and
18
+ // Prisma performs no encryption of its own.
19
+ //
20
+ // NEVER select `secret` into anything that reaches an API response or a log
21
+ // line. `CredentialsService` is the sole reader/writer and is what actually
22
+ // enforces that split (a plaintext-returning method for server-side
23
+ // consumers, a metadata-only method for presentation) — this comment is the
24
+ // signpost, not the guarantee.
25
+ // @extensible — back-relations only, added with "extend model Credential { ... }" in a
26
+ // fragment, so an app can hang its own NON-SECRET bookkeeping on a deployment credential
27
+ // (which feature uses it, when it was last rotated). The `secret` column is never the
28
+ // app's to read or relate to: an app reads plaintext only through `CredentialsService`,
29
+ // and no app table may copy ciphertext or plaintext of it.
30
+ model Credential {
31
+ id String @id @default(uuid()) @db.Uuid
32
+ purpose String
33
+ name String
34
+ // Opaque [iv][authTag][ciphertext] payload, base64-encoded by
35
+ // encryptSecret(). @db.Text (not the default `varchar`) because a base64
36
+ // AES-GCM payload has no meaningful bound: encrypted key material,
37
+ // passwords, or tokens of arbitrary length must never be truncated.
38
+ secret String @db.Text
39
+ // Non-secret display aid for the admin UI (e.g. last 4 chars, or a fixed
40
+ // mask like '••••'). Nullable: a credential can exist before anyone has
41
+ // decided how to hint it, and some secrets have no meaningful suffix to show.
42
+ hint String?
43
+ // Human-readable description for the admin UI. Nullable for the same reason.
44
+ label String?
45
+ updatedByUserId String? @map("updated_by_user_id") @db.Uuid
46
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
47
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
48
+
49
+ // Relations
50
+ //
51
+ // onDelete: SetNull — deliberately NOT Cascade. A credential is
52
+ // infrastructure shared by whatever consumer looks it up by (purpose,
53
+ // name); the admin who last set it is provenance, not an owner the
54
+ // credential's lifetime should depend on. Offboarding the user who last
55
+ // typed in the SMTP password must not silently delete a working SMTP
56
+ // configuration — that failure mode (email breaks because someone left
57
+ // the company) is exactly what this FK behaviour avoids. The credential
58
+ // outliving its last editor is correct; losing it is not.
59
+ updatedByUser User? @relation("CredentialUpdatedBy", fields: [updatedByUserId], references: [id], onDelete: SetNull)
60
+
61
+ @@unique([purpose, name])
62
+ @@map("credentials")
63
+ }
64
+
65
+ // =============================================================================
66
+ // Per-User Encrypted Credentials (issue #387)
67
+ // =============================================================================
68
+ //
69
+ // The sibling of `Credential` above for secrets OWNED BY ONE USER — a user's
70
+ // own key for some external service (bring-your-own-key). Addressed by
71
+ // (userId, purpose, name); `credentials` is untouched and keeps its
72
+ // ownerless (purpose, name) address.
73
+ //
74
+ // `secret` is the same opaque base64 [iv][authTag][ciphertext] payload
75
+ // `encryptSecret()` produces, but encrypted under an OWNER-BOUND sub-key
76
+ // domain, `user:<userId>:<purpose>` (`userCredentialPurpose()` in
77
+ // `@marinoscar/platform-api/core`), not under `purpose` alone. A ciphertext
78
+ // copied from user A's row into user B's row therefore fails GCM
79
+ // authentication instead of handing B the use of A's key.
80
+ //
81
+ // All three unique columns are NOT NULL, so `@@unique` is a real Postgres
82
+ // constraint (NULLs are distinct in a unique index) and `findUnique`/`upsert`
83
+ // on `userId_purpose_name` work. Its leading column also serves every
84
+ // by-user lookup, so there is no separate `user_id` index.
85
+ //
86
+ // onDelete: Cascade — deliberately the OPPOSITE of `Credential.updatedByUser`
87
+ // (SetNull). There, the user is provenance for shared infrastructure; here,
88
+ // the user is the owner, and a secret nobody owns is a secret nobody can
89
+ // revoke. `UserCredentialsService` is the sole reader/writer; NEVER select
90
+ // `secret` into anything that reaches an API response or a log line.
91
+ model UserCredential {
92
+ id String @id @default(uuid()) @db.Uuid
93
+ userId String @map("user_id") @db.Uuid
94
+ purpose String
95
+ name String
96
+ // @db.Text for the same reason as `Credential.secret`: never truncate.
97
+ secret String @db.Text
98
+ // Non-secret display aid, derived from the plaintext on write.
99
+ hint String?
100
+ // Human-readable description. User-entered, non-secret.
101
+ label String?
102
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
103
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
104
+
105
+ user User @relation("UserCredentials", fields: [userId], references: [id], onDelete: Cascade)
106
+
107
+ @@unique([userId, purpose, name])
108
+ @@map("user_credentials")
109
+ }
110
+
111
+ // =============================================================================
112
+ // Organization-Owned Encrypted Credentials (issue #735, PP-8.8)
113
+ // =============================================================================
114
+ //
115
+ // The third tier: secrets OWNED BY ONE ORGANIZATION (an org's own AI provider
116
+ // key, an org's own storage or e-mail account), addressed by
117
+ // (orgId, purpose, name). It mirrors `UserCredential` exactly, with the owner
118
+ // an organization instead of a user.
119
+ //
120
+ // `secret` is the opaque [iv][authTag][ciphertext] payload `encryptSecret()`
121
+ // produces under the ORG-BOUND sub-key domain `org:<orgId>:<purpose>`
122
+ // (`orgCredentialPurpose()` in `@marinoscar/platform-api/core`): a ciphertext
123
+ // copied from one organization's row into another's fails GCM
124
+ // authentication.
125
+ //
126
+ // A plain composite unique suffices (no raw-SQL index): all three columns are
127
+ // NOT NULL. Its leading column serves every by-org lookup.
128
+ //
129
+ // TENANT DATA: `org_credentials` is an `org` table under FORCEd row-level
130
+ // security (`org_credentials_org_isolation`, as in 0025/0026), so every read
131
+ // and write runs in the owning organization's scope. Cascade with the
132
+ // organization; the last editor is provenance only (SetNull), as on
133
+ // `Credential`. `OrgCredentialsService` is the sole reader/writer; NEVER
134
+ // select `secret` into anything that reaches an API response or a log line.
135
+ // =============================================================================
136
+ model OrgCredential {
137
+ id String @id @default(uuid()) @db.Uuid
138
+ orgId String @map("org_id") @db.Uuid
139
+ purpose String
140
+ name String
141
+ // @db.Text for the same reason as `Credential.secret`: never truncate.
142
+ secret String @db.Text
143
+ // Non-secret display aid, derived from the plaintext on write.
144
+ hint String?
145
+ // Human-readable description. Admin-entered, non-secret.
146
+ label String?
147
+ updatedByUserId String? @map("updated_by_user_id") @db.Uuid
148
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
149
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
150
+
151
+ org Organization @relation(fields: [orgId], references: [id], onDelete: Cascade)
152
+ updatedByUser User? @relation("OrgCredentialUpdatedBy", fields: [updatedByUserId], references: [id], onDelete: SetNull)
153
+
154
+ @@unique([orgId, purpose, name])
155
+ @@map("org_credentials")
156
+ }