@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,1030 @@
1
+ // GENERATED by platform db compose — do not edit
2
+ // source: app:kvox.graph.prisma
3
+
4
+ // GENERATED by kvox-db sync — do not edit
5
+ // source: @marinoscar/kvox-db:schema/kvox.graph.prisma
6
+
7
+ // =============================================================================
8
+ // Connected Knowledge (epic #344)
9
+ // =============================================================================
10
+ //
11
+ // The graph foundation: entities, relations, items (commitments, decisions,
12
+ // claims, person facts), the evidence that anchors every claim back to a
13
+ // transcript segment or note span, and the review-and-commit proposal model
14
+ // extraction runs through. Full design in `docs/specs/ontology.md`; §10 is
15
+ // this section's own summary, kept in sync with the column list below.
16
+ // SCHEMA ONLY in this migration — no service, no endpoint, no data. The
17
+ // evidence-invariant trigger is #355's own migration, landing with the code
18
+ // that satisfies it; `kg_graph_layouts` and `ask_*` are #371's and #376's
19
+ // own migrations.
20
+ //
21
+ // Five conventions every model below shares, stated once here rather than
22
+ // repeated per model:
23
+ //
24
+ // 1. `owner_id → users.id`, `onDelete: Cascade`, on every table but one. An
25
+ // entity/relation/item/evidence row/mention/proposal/merge/distinct-pair/
26
+ // attribute-def has no meaning and no permission path to read it once its
27
+ // owner is gone — the identical reasoning `notes.owner_id`/
28
+ // `transcripts.owner_id` already establish, and there is no
29
+ // `graph:read_any` for the identical reason there is no `notes:read_any`.
30
+ // `kg_entity_views` is the one exception: it carries `user_id`, because
31
+ // "which entities has THIS user looked at" is a per-viewer fact, not an
32
+ // ownership fact.
33
+ // 2. `type`/`kind` columns that name an ONTOLOGY key (`kg_entities.type`,
34
+ // `kg_relations.type`) are plain TEXT, never a Prisma enum — the same
35
+ // "small, code-owned, growing set" reasoning `jobs.type` and
36
+ // `storage_objects.managed_by` already establish elsewhere in this
37
+ // schema. Every enum below instead closes a set this SCHEMA itself
38
+ // enforces (a review lifecycle, a proposal's own status machine) — see
39
+ // §17.4 for the full type-vs-enum argument.
40
+ // 3. `props`/`stats`/`user_guidance` JSONB columns default `'{}'` where
41
+ // listed, exactly like `jobs.payload`/`kg_entities.props` elsewhere in
42
+ // this file — an empty object, never NULL, so a reader never has to
43
+ // special-case "no extra data yet" against "extra data exists but is
44
+ // empty."
45
+ // 4. `ontology_version` (text) on every row a §17.4 definition-file version
46
+ // could apply to (`kg_entities`, `kg_relations`, `kg_items`) — which
47
+ // version of the ontology's own type/attribute definitions this row was
48
+ // written against, so a later definition-file migration can tell an
49
+ // old row from a new one without re-deriving it.
50
+ // 5. `valid tstzrange` (on `kg_relations` and `kg_items`) is written and
51
+ // read entirely OUTSIDE Prisma's query API. Prisma cannot construct,
52
+ // bind, or select a `tstzrange` value — the column is declared
53
+ // `Unsupported("tstzrange")` below specifically so the generated client
54
+ // refuses to touch it at all, the same "declare it unsupported so a
55
+ // mistaken ORM write is impossible" shape `SearchEmbedding.embedding`
56
+ // already establishes for `vector(1536)`. Writers set `valid` with
57
+ // `$executeRaw` inside the SAME transaction as the `create`; readers
58
+ // select `lower(valid)`, `upper(valid)` and `lower_inc(valid)`/
59
+ // `upper_inc(valid)` via `$queryRaw`. #353 provides the `toPgRange`/
60
+ // `fromPgRange` helpers every future reader/writer uses instead of
61
+ // reinventing this.
62
+ //
63
+ // -----------------------------------------------------------------------------
64
+ // WHY THERE IS NO ANCHOR CHECK ON `kg_evidence`.
65
+ // -----------------------------------------------------------------------------
66
+ //
67
+ // Every FK `kg_evidence` holds into another module's table
68
+ // (`transcript_id`/`segment_id`/`note_id`/`import_object_id`) is `SetNull`,
69
+ // by design (§10) — an evidence row is a CITATION, and a citation survives
70
+ // losing the thing it pointed to; `quote` (NOT NULL) is stored precisely so
71
+ // the citation stays readable once every anchor has gone to NULL. A CHECK
72
+ // requiring at least one anchor to be non-NULL would make deleting a
73
+ // transcript FAIL the moment its own evidence rows lose their last pointer —
74
+ // exactly the failure `SetNull` exists to avoid. So there is deliberately no
75
+ // such CHECK: a fully-anchorless evidence row (every FK NULL, `quote` intact)
76
+ // is a valid, expected end state, not a bug to prevent.
77
+ //
78
+ // -----------------------------------------------------------------------------
79
+ // INTENTIONAL SCHEMA DRIFT — HAND-WRITTEN, PRISMA-INEXPRESSIBLE SQL LIVES
80
+ // ONLY IN `migration.sql`, THE SAME PATTERN `jobs_active_dedup_uniq_idx` AND
81
+ // `search_embeddings_embedding_hnsw_idx` ALREADY ESTABLISH.
82
+ // -----------------------------------------------------------------------------
83
+ //
84
+ // `migration.sql` for this section hand-writes:
85
+ // - `CREATE EXTENSION IF NOT EXISTS pg_trgm` — a new capability for this
86
+ // codebase (pgvector already is not new; `SearchEmbedding` already
87
+ // depends on it). Trusted on PG13+, so the migration role needs no
88
+ // superuser.
89
+ // - Two `gin (… gin_trgm_ops)` trigram indexes (fuzzy candidate generation,
90
+ // §7): `kg_entities_label_trgm_idx`, `kg_entity_aliases_normalized_trgm_idx`.
91
+ // - Two `gist (valid)` range indexes (§5.4's containment/overlap queries):
92
+ // `kg_relations_valid_gist_idx`, `kg_items_valid_gist_idx`.
93
+ // - Two `hnsw (embedding vector_cosine_ops)` indexes, matching
94
+ // `SearchEmbedding`'s own hand-written index discipline exactly:
95
+ // `kg_entities_embedding_hnsw_idx`, `kg_items_embedding_hnsw_idx`.
96
+ // - Five partial unique indexes and one CHECK-shaped guard, all below.
97
+ // - Eight CHECK constraints, all below.
98
+ // None of the above has a Prisma DSL equivalent; `prisma migrate dev`/`diff`
99
+ // will propose to drop every one of them and must never be asked to
100
+ // reconcile that "drift" away.
101
+ //
102
+ // -----------------------------------------------------------------------------
103
+ // FIVE PARTIAL UNIQUE INDEXES, EACH ENFORCED IN THE DATABASE, NEVER BY A
104
+ // `findFirst` BEFORE THE INSERT — THE `database_backup_runs_active_uniq_idx`
105
+ // REASONING, FIVE TIMES OVER.
106
+ // -----------------------------------------------------------------------------
107
+ //
108
+ // - `kg_items_live_statement_uniq_idx` — the "known, skipped" dedup guard
109
+ // (§8): a verbatim restatement of an already-`accepted`/`edited` item
110
+ // attaches new evidence instead of inserting a duplicate row. Scoped to
111
+ // `review_status IN ('accepted','edited')` and `subject_id IS NOT NULL`
112
+ // so a `rejected` row's hash never blocks a legitimate later insert.
113
+ // - `kg_proposals_note_draft_uniq_idx` — at most one OPEN draft per note.
114
+ // Whoever creates a new draft discards the previous one in the same
115
+ // transaction (#363); this index is what makes "at most one" true even
116
+ // under a race, not the service-layer discard alone.
117
+ // - `kg_proposals_note_extracting_uniq_idx` — at most one RUNNING
118
+ // extraction per note (#363's 409 `extraction_running`).
119
+ // - `kg_proposals_owner_import_extracting_uniq_idx` — at most one RUNNING
120
+ // import per OWNER (#387's), scoped by `owner_id` rather than `note_id`
121
+ // because an import proposal has no note at all.
122
+ // - `kg_relations_speaker_link_uniq_idx` — at most one `IDENTIFIED_AS`
123
+ // edge per diarized speaker (a speaker names at most one entity).
124
+ //
125
+ // -----------------------------------------------------------------------------
126
+ // EIGHT CHECK CONSTRAINTS.
127
+ // -----------------------------------------------------------------------------
128
+ //
129
+ // - `kg_distinct_pairs_order_chk` (`a_id < b_id`) — canonicalizes an
130
+ // unordered pair into exactly one storable row, so `(a, b)` and `(b, a)`
131
+ // can never both be inserted as two different "confirmed distinct" facts.
132
+ // - `kg_mentions_one_source_chk` (`num_nonnulls(note_id, transcript_id) =
133
+ // 1`) — a mention always comes from exactly one source document.
134
+ // - `kg_relations_one_source_chk` (`num_nonnulls(from_id, from_speaker_id)
135
+ // = 1`) — a relation's `from` side is EITHER a resolved entity OR a
136
+ // diarized speaker awaiting `IDENTIFIED_AS`, never both, never neither.
137
+ // - `kg_relations_speaker_type_chk` (`from_speaker_id IS NULL OR type =
138
+ // 'IDENTIFIED_AS'`) — `from_speaker_id` exists for exactly one
139
+ // relation type, and no other.
140
+ // - `kg_relations_valid_precision_chk` (`(valid IS NULL) =
141
+ // (valid_precision IS NULL) OR valid_precision IS NOT DISTINCT FROM
142
+ // 'unknown'` — `IS NOT DISTINCT FROM`, not `=`, since #398: with `=` a
143
+ // NULL precision made the CHECK evaluate NULL, which passes) — a
144
+ // temporal relation always states a precision, but `unknown` is
145
+ // representable even should `valid` itself later become NULL (e.g. a
146
+ // future correction that clears a range while a precision is still on
147
+ // record as having once been claimed).
148
+ // - `kg_items_sensitivity_chk` (`(kind = 'person_fact') = (sensitivity IS
149
+ // NOT NULL)`) — sensitivity is meaningful for exactly one item kind
150
+ // (§5.6), and this makes "a claim with a sensitivity" and "a person
151
+ // fact with none" both unrepresentable rather than merely discouraged.
152
+ // - `kg_items_subject_required_chk` (`kind NOT IN ('claim','person_fact')
153
+ // OR subject_id IS NOT NULL`) — a claim or a person fact is always
154
+ // ABOUT something; a commitment/decision may stand on its own.
155
+ // - `kg_evidence_char_range_chk` / `kg_evidence_ms_range_chk` — an end
156
+ // offset, when both ends are given, is never before its start.
157
+ // - `kg_proposal_items_kind_payload_chk` (`jsonb_typeof(payload) =
158
+ // 'object'`) — `payload` is always a JSON object, never a bare array or
159
+ // scalar, so every reader can assume key access without a type guard.
160
+ // - `kg_proposal_items_merge_into_chk` (`merge_into_id IS NULL OR decision
161
+ // = 'merge_into'`) — the override column and the decision it overrides
162
+ // can never disagree about which decision this row represents.
163
+ // `merge_into_id` is `SetNull`: forgetting or purging the target entity
164
+ // clears the override, and the row falls back to `pending` semantics at
165
+ // commit (#366 refuses a `merge_into` row without a target).
166
+ // - `kg_proposals_note_source_chk` (`CASE WHEN kind = 'extraction' THEN
167
+ // note_version IS NOT NULL ELSE note_id IS NULL AND note_version IS
168
+ // NULL END`) — keys on `note_version`, NEVER on `note_id`, and this is
169
+ // deliberate: `note_id` is inserted non-null for every extraction
170
+ // proposal and can only become NULL afterwards through the note's own
171
+ // `SetNull` FK once the note is hard-deleted. A CHECK keyed on `note_id`
172
+ // would then start failing for rows that were valid when written; a
173
+ // CHECK keyed on `note_version` (never nulled by any FK action) stays
174
+ // satisfied for the life of the row.
175
+ //
176
+ // -----------------------------------------------------------------------------
177
+ // FUTURE MIGRATIONS: when a later `prisma migrate dev` proposes `DROP INDEX`
178
+ // for any hand-written name above, DELETE those proposed lines. This is
179
+ // intentional drift, not decay to fix.
180
+ // =============================================================================
181
+
182
+ enum KgReviewStatus {
183
+ unreviewed
184
+ accepted
185
+ edited
186
+ rejected
187
+ merged
188
+ superseded
189
+
190
+ @@map("kg_review_status")
191
+ }
192
+
193
+ enum KgValidPrecision {
194
+ day
195
+ month
196
+ year
197
+ unknown
198
+
199
+ @@map("kg_valid_precision")
200
+ }
201
+
202
+ enum KgItemKind {
203
+ commitment
204
+ decision
205
+ claim
206
+ person_fact
207
+
208
+ @@map("kg_item_kind")
209
+ }
210
+
211
+ enum KgSensitivity {
212
+ business
213
+ personal
214
+ sensitive
215
+
216
+ @@map("kg_sensitivity")
217
+ }
218
+
219
+ enum KgAliasSource {
220
+ user
221
+ extraction
222
+ speaker_naming
223
+ import
224
+
225
+ @@map("kg_alias_source")
226
+ }
227
+
228
+ enum KgEvidenceSubjectKind {
229
+ entity
230
+ relation
231
+ item
232
+ proposal_item
233
+ import
234
+
235
+ @@map("kg_evidence_subject_kind")
236
+ }
237
+
238
+ enum KgMentionStatus {
239
+ suggested
240
+ linked
241
+ new
242
+ ignored
243
+
244
+ @@map("kg_mention_status")
245
+ }
246
+
247
+ // The COMPLETE proposal model, up front — every status and kind #363, #364,
248
+ // #366 and #387 need, so none of them ever needs `ALTER TYPE … ADD VALUE` or
249
+ // a proposal-table migration of its own. `extracting`/`resolution` are used
250
+ // by no code yet; #363 owns the prose for the `extracting` lifecycle and the
251
+ // `resolution` kind, this migration owns only the column list (issue #351).
252
+ enum KgProposalKind {
253
+ extraction
254
+ import
255
+ resolution
256
+
257
+ @@map("kg_proposal_kind")
258
+ }
259
+
260
+ // Lifecycle: `extracting → draft | failed`, `draft → committed | discarded`,
261
+ // `failed → discarded`, `committed → reverted`. Transitions are enforced by
262
+ // the SERVICES (#363/#366), not by a database trigger — this enum only
263
+ // closes the set of states a row may ever be in.
264
+ enum KgProposalStatus {
265
+ draft
266
+ extracting
267
+ committed
268
+ discarded
269
+ failed
270
+ reverted
271
+
272
+ @@map("kg_proposal_status")
273
+ }
274
+
275
+ enum KgProposalItemKind {
276
+ entity
277
+ relation
278
+ item
279
+ closing
280
+
281
+ @@map("kg_proposal_item_kind")
282
+ }
283
+
284
+ enum KgProposalItemDecision {
285
+ pending
286
+ accept
287
+ edit
288
+ reject
289
+ merge_into
290
+
291
+ @@map("kg_proposal_item_decision")
292
+ }
293
+
294
+ enum KgProposalItemOrigin {
295
+ ai
296
+ user
297
+
298
+ @@map("kg_proposal_item_origin")
299
+ }
300
+
301
+ enum KgAttributeKind {
302
+ text
303
+ number
304
+ date
305
+ boolean
306
+ select
307
+ multi_select
308
+ url
309
+ entity_ref
310
+
311
+ @@map("kg_attribute_kind")
312
+ }
313
+
314
+ // -----------------------------------------------------------------------------
315
+ // `kg_entities` — Person, Organization, Project, Meeting (`type` is a plain
316
+ // ontology key, §17.4 — never a Prisma enum; see the section header above).
317
+ // `owner_id` Cascade — see convention 1 above. `embedding vector(1536)`
318
+ // reuses the EXACT model/dimension contract `SearchEmbedding` already
319
+ // carries (see that model's own block comment for the full "why
320
+ // `Unsupported`, why raw SQL" argument) rather than inventing a second
321
+ // embedding convention; `embedding_hash` is the content hash #364's
322
+ // `kg.embed` keys re-embedding on, mirroring `SearchChunk.contentHash`.
323
+ // `merged_into_id` is a self-relation: once an entity is merged into
324
+ // another (§7), this points at the survivor and `review_status` moves to
325
+ // `merged` — SetNull so deleting the survivor un-merges rather than
326
+ // cascading a delete backward onto every entity it absorbed.
327
+ // `occurred_at` is set on `Meeting` rows only (the ontology's own type
328
+ // contract, not a database constraint — a fork's own type could reuse it
329
+ // differently).
330
+ // -----------------------------------------------------------------------------
331
+ model KgEntity {
332
+ id String @id @default(uuid()) @db.Uuid
333
+
334
+ ownerId String @map("owner_id") @db.Uuid
335
+ type String
336
+ label String
337
+ props Json @default("{}") @db.JsonB
338
+
339
+ // See the section header's convention 5 — `Unsupported`, written/read only
340
+ // through `$executeRaw`/`$queryRaw`, matching `SearchEmbedding.embedding`.
341
+ // Written as `Unsupported("vector(1536)")` with the parentheses as \u0028/\u0029: the
342
+ // composer's field reader (platform-db 0.1.0-next.5) stops a type at the first `)`.
343
+ // Prisma decodes the escapes, so the column type is still vector(1536).
344
+ embedding Unsupported("vector\u00281536\u0029")?
345
+ embeddingModel String? @map("embedding_model")
346
+ embeddingHash String? @map("embedding_hash")
347
+
348
+ reviewStatus KgReviewStatus @default(accepted) @map("review_status")
349
+ mergedIntoId String? @map("merged_into_id") @db.Uuid
350
+ occurredAt DateTime? @map("occurred_at") @db.Timestamptz
351
+
352
+ ontologyVersion String @map("ontology_version")
353
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
354
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
355
+
356
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
357
+ mergedInto KgEntity? @relation("KgEntityMergedInto", fields: [mergedIntoId], references: [id], onDelete: SetNull)
358
+
359
+ mergedFrom KgEntity[] @relation("KgEntityMergedInto")
360
+ aliases KgEntityAlias[]
361
+ relationsFrom KgRelation[] @relation("KgRelationFrom")
362
+ relationsTo KgRelation[] @relation("KgRelationTo")
363
+ itemsAsSubject KgItem[] @relation("KgItemSubject")
364
+ itemsAsMeeting KgItem[] @relation("KgItemMeeting")
365
+ itemsAsOwnerPerson KgItem[] @relation("KgItemOwnerPerson")
366
+ itemsAsCounterparty KgItem[] @relation("KgItemCounterparty")
367
+ mentions KgMention[]
368
+ proposalItemsMergedInto KgProposalItem[] @relation("KgProposalItemMergeInto")
369
+ mergesAsSurvivor KgMerge[] @relation("KgMergeSurvivor")
370
+ mergesAsMerged KgMerge[] @relation("KgMergeMerged")
371
+ distinctPairsAsA KgDistinctPair[] @relation("KgDistinctPairA")
372
+ distinctPairsAsB KgDistinctPair[] @relation("KgDistinctPairB")
373
+ digest KgEntityDigest?
374
+ views KgEntityView[]
375
+ // Ask conversations scoped to this entity (issue #376). SetNull on the
376
+ // conversation's side — forgetting or merging a person drops the scope
377
+ // link, never the user's own conversation.
378
+ askConversationsScoped AskConversation[]
379
+
380
+ // "This owner's entities of type X, by review state" and "this owner's
381
+ // entities, recently updated" — the two shapes an entity list/filter reads.
382
+ @@index([ownerId, type, reviewStatus])
383
+ @@index([ownerId, updatedAt(sort: Desc)])
384
+ // "Every entity that merged into this one" — the merge-reversal read path.
385
+ @@index([mergedIntoId])
386
+ // HAND-WRITTEN in migration.sql only: `kg_entities_label_trgm_idx` (gin,
387
+ // gin_trgm_ops) and `kg_entities_embedding_hnsw_idx` (hnsw,
388
+ // vector_cosine_ops). See the section header's "INTENTIONAL SCHEMA DRIFT"
389
+ // note — Prisma has no DSL for either.
390
+ @@map("kg_entities")
391
+ }
392
+
393
+ // -----------------------------------------------------------------------------
394
+ // `kg_entity_aliases` — a SEPARATE table, not an array column on
395
+ // `kg_entities`, specifically because an alias needs its own indexed
396
+ // exact/fuzzy lookup AND its own provenance (`source`) — an array column
397
+ // could hold the strings but not which of four very different origins each
398
+ // one came from (§10). `normalized` is produced by #355's `normalizeAlias()`
399
+ // (NFKC, lowercase, trimmed, whitespace collapsed) — this REPLACES `citext`,
400
+ // which is not installed and is not needed: a normalized `text` column
401
+ // produced by one pure function gives exact matching AND feeds `pg_trgm`
402
+ // directly, at the cost of nothing `citext` would have bought here.
403
+ // `entity_id`/`owner_id` both Cascade — an alias has no meaning once either
404
+ // its entity or its owner is gone.
405
+ // -----------------------------------------------------------------------------
406
+ model KgEntityAlias {
407
+ id String @id @default(uuid()) @db.Uuid
408
+ entityId String @map("entity_id") @db.Uuid
409
+ ownerId String @map("owner_id") @db.Uuid
410
+
411
+ alias String
412
+ // See the block comment above the model for the full `normalizeAlias()`
413
+ // argument — this is why there is no `citext` column here.
414
+ normalized String
415
+
416
+ source KgAliasSource
417
+
418
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
419
+
420
+ entity KgEntity @relation(fields: [entityId], references: [id], onDelete: Cascade)
421
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
422
+
423
+ // One alias per (entity, normalized form) — a repeat submission of the
424
+ // identically-normalized alias is a no-op, not a duplicate row.
425
+ @@unique([entityId, normalized])
426
+ @@index([ownerId, normalized])
427
+ @@index([entityId])
428
+ // HAND-WRITTEN in migration.sql only: `kg_entity_aliases_normalized_trgm_idx`
429
+ // (gin, gin_trgm_ops) — fuzzy candidate generation (§7). See the section
430
+ // header's "INTENTIONAL SCHEMA DRIFT" note.
431
+ @@map("kg_entity_aliases")
432
+ }
433
+
434
+ // -----------------------------------------------------------------------------
435
+ // `kg_relations` — `type` is one of §5.2's sixteen ontology edge types
436
+ // (plain text, never a Prisma enum — convention 2 above). No inverse row is
437
+ // EVER stored (§5.2): a "from" side is either a resolved `KgEntity`
438
+ // (`from_id`) or a diarized speaker awaiting identification
439
+ // (`from_speaker_id`) — `kg_relations_one_source_chk` makes exactly one of
440
+ // the two representable, and `kg_relations_speaker_type_chk` restricts
441
+ // `from_speaker_id` to the one relation type (`IDENTIFIED_AS`) whose source
442
+ // is a speaker rather than an entity. This is a contract addition beyond
443
+ // spec §10's original text (§5.1/§5.2): a polymorphic `from_kind`/`from_id`
444
+ // pair without FKs was rejected in favour of a REAL FK to
445
+ // `transcript_speakers`, so deleting a merged speaker's row cleans up the
446
+ // edge for free. `valid`/`valid_precision` are the §5.4 bitemporal pair —
447
+ // see the section header's convention 5 for how `valid` is actually
448
+ // written/read. `superseded_by_id` is a self-relation (§5.5's "closing a
449
+ // temporal edge" case), SetNull so deleting the successor un-supersedes
450
+ // rather than cascading backward.
451
+ // -----------------------------------------------------------------------------
452
+ model KgRelation {
453
+ id String @id @default(uuid()) @db.Uuid
454
+ ownerId String @map("owner_id") @db.Uuid
455
+ type String
456
+
457
+ // Endpoints — see the block comment above the model for the full
458
+ // exactly-one-of argument enforced by `kg_relations_one_source_chk`.
459
+ fromId String? @map("from_id") @db.Uuid
460
+ fromSpeakerId String? @map("from_speaker_id") @db.Uuid
461
+ toId String @map("to_id") @db.Uuid
462
+
463
+ props Json @default("{}") @db.JsonB
464
+
465
+ // §5.4's bitemporal pair. See the section header's convention 5 for why
466
+ // `valid` is `Unsupported` and written/read only via raw SQL.
467
+ valid Unsupported("tstzrange")?
468
+ validPrecision KgValidPrecision? @map("valid_precision")
469
+
470
+ reviewStatus KgReviewStatus @default(accepted) @map("review_status")
471
+ confidence Float?
472
+ supersededById String? @map("superseded_by_id") @db.Uuid
473
+
474
+ ontologyVersion String @map("ontology_version")
475
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
476
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
477
+
478
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
479
+ fromEntity KgEntity? @relation("KgRelationFrom", fields: [fromId], references: [id], onDelete: Cascade)
480
+ fromSpeaker TranscriptSpeaker? @relation(fields: [fromSpeakerId], references: [id], onDelete: Cascade)
481
+ toEntity KgEntity @relation("KgRelationTo", fields: [toId], references: [id], onDelete: Cascade)
482
+ supersededBy KgRelation? @relation("KgRelationSupersededBy", fields: [supersededById], references: [id], onDelete: SetNull)
483
+
484
+ supersedes KgRelation[] @relation("KgRelationSupersededBy")
485
+
486
+ // §5.2's "joins are symmetric" argument, made concrete: a relation is
487
+ // looked up from either endpoint equally often.
488
+ @@index([ownerId, fromId, type])
489
+ @@index([ownerId, toId, type])
490
+ @@index([fromSpeakerId])
491
+ @@index([supersededById])
492
+ @@index([ownerId, type, reviewStatus])
493
+ // HAND-WRITTEN in migration.sql only: `kg_relations_valid_gist_idx` (gist,
494
+ // §5.4's containment/overlap queries) and `kg_relations_speaker_link_uniq_idx`
495
+ // (a partial unique index — see the section header). See the section
496
+ // header's "INTENTIONAL SCHEMA DRIFT" note.
497
+ @@map("kg_relations")
498
+ }
499
+
500
+ // -----------------------------------------------------------------------------
501
+ // `kg_items` — Commitment, Decision, Claim, and PersonFact in ONE table,
502
+ // distinguished by `kind` (a real Prisma enum here, unlike `kg_entities
503
+ // .type`/`kg_relations.type` — see §10, this is the schema's own closed
504
+ // four-way split, not an open ontology key). `meeting_id` backs
505
+ // `CREATED_IN`/`DECIDED_IN` (a contract addition beyond the original §10
506
+ // text) — SetNull, so deleting the meeting entity leaves the item itself
507
+ // intact with a now-unknown venue rather than deleting the item.
508
+ // `owner_person_id`/`counterparty_id` Cascade (deleting the person this
509
+ // commitment is FOR/BETWEEN removes the commitment's meaning entirely, the
510
+ // same argument as `subject_id`); `subject_id` also Cascade. `sensitivity`
511
+ // is meaningful for exactly one `kind` (§5.6), enforced by
512
+ // `kg_items_sensitivity_chk`. `statement_hash` is the §7 dedup/suppression
513
+ // key `kg_items_live_statement_uniq_idx` enforces. `valid`/`valid_precision`
514
+ // mirror `kg_relations`'s own bitemporal pair (§5.4) so an item's own valid
515
+ // period is never a second stored shape from its edges' shape.
516
+ // -----------------------------------------------------------------------------
517
+ model KgItem {
518
+ id String @id @default(uuid()) @db.Uuid
519
+ ownerId String @map("owner_id") @db.Uuid
520
+ kind KgItemKind
521
+
522
+ subjectId String? @map("subject_id") @db.Uuid
523
+ meetingId String? @map("meeting_id") @db.Uuid
524
+ ownerPersonId String? @map("owner_person_id") @db.Uuid
525
+ counterpartyId String? @map("counterparty_id") @db.Uuid
526
+
527
+ title String?
528
+ // Commitment: `open | done | dropped | superseded`. Others: `active |
529
+ // superseded`. Plain text, not an enum — the same "small, code-owned set
530
+ // that varies per `kind`" reasoning `props`/§10 already give this table
531
+ // its single-table shape for.
532
+ statement String
533
+ status String
534
+ props Json @default("{}") @db.JsonB
535
+
536
+ occurredAt DateTime? @map("occurred_at") @db.Timestamptz
537
+ dueAt DateTime? @map("due_at") @db.Timestamptz
538
+
539
+ supersededById String? @map("superseded_by_id") @db.Uuid
540
+ sensitivity KgSensitivity?
541
+ // The §7 dedup/suppression key — see `kg_items_live_statement_uniq_idx`
542
+ // below (hand-written, since Prisma cannot express its `WHERE` clause).
543
+ statementHash String @map("statement_hash")
544
+
545
+ // §5.4's bitemporal pair, mirroring `kg_relations` exactly. See the
546
+ // section header's convention 5 for why `valid` is `Unsupported`.
547
+ valid Unsupported("tstzrange")?
548
+ validPrecision KgValidPrecision? @map("valid_precision")
549
+
550
+ // See the section header's convention 5 — same `Unsupported`/raw-SQL
551
+ // contract as `kg_entities.embedding` and `SearchEmbedding.embedding`.
552
+ // Written as `Unsupported("vector(1536)")` with the parentheses as \u0028/\u0029: the
553
+ // composer's field reader (platform-db 0.1.0-next.5) stops a type at the first `)`.
554
+ // Prisma decodes the escapes, so the column type is still vector(1536).
555
+ embedding Unsupported("vector\u00281536\u0029")?
556
+ embeddingModel String? @map("embedding_model")
557
+ embeddingHash String? @map("embedding_hash")
558
+
559
+ reviewStatus KgReviewStatus @default(accepted) @map("review_status")
560
+ confidence Float?
561
+
562
+ ontologyVersion String @map("ontology_version")
563
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
564
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
565
+
566
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
567
+ subject KgEntity? @relation("KgItemSubject", fields: [subjectId], references: [id], onDelete: Cascade)
568
+ meeting KgEntity? @relation("KgItemMeeting", fields: [meetingId], references: [id], onDelete: SetNull)
569
+ ownerPerson KgEntity? @relation("KgItemOwnerPerson", fields: [ownerPersonId], references: [id], onDelete: Cascade)
570
+ counterparty KgEntity? @relation("KgItemCounterparty", fields: [counterpartyId], references: [id], onDelete: Cascade)
571
+ supersededBy KgItem? @relation("KgItemSupersededBy", fields: [supersededById], references: [id], onDelete: SetNull)
572
+
573
+ supersedes KgItem[] @relation("KgItemSupersededBy")
574
+
575
+ // §9.1's "what's changed for this entity" brief query.
576
+ @@index([ownerId, subjectId, occurredAt(sort: Desc)])
577
+ @@index([ownerId, kind, status])
578
+ @@index([ownerId, statementHash])
579
+ @@index([ownerPersonId])
580
+ @@index([counterpartyId])
581
+ @@index([meetingId])
582
+ @@index([supersededById])
583
+ // HAND-WRITTEN in migration.sql only: `kg_items_valid_gist_idx` (gist),
584
+ // `kg_items_embedding_hnsw_idx` (hnsw, vector_cosine_ops), and
585
+ // `kg_items_live_statement_uniq_idx` (a partial unique index — the "known,
586
+ // skipped" §8 dedup guard). See the section header's "INTENTIONAL SCHEMA
587
+ // DRIFT" note.
588
+ @@map("kg_items")
589
+ }
590
+
591
+ // -----------------------------------------------------------------------------
592
+ // `kg_evidence` — the §5.3 evidence contract. `subject_kind`/`subject_id`
593
+ // are POLYMORPHIC, carrying NO foreign key — the identical `jobs.subject_
594
+ // type`/`subject_id` pattern this schema already uses, because the set of
595
+ // things evidence can anchor (an entity, a relation, an item, a proposal
596
+ // item, an import) is open-ended and code-owned. The transcript/note/import
597
+ // anchors ARE real foreign keys, and every one of them is `SetNull` — see
598
+ // the section header's "WHY THERE IS NO ANCHOR CHECK" note for the full
599
+ // argument; `quote` (NOT NULL) is what keeps a citation readable once every
600
+ // anchor has gone to NULL. `char_start`/`char_end` are offsets into the
601
+ // cited NOTE VERSION BODY when `note_id` is set, or into the cited
602
+ // SEGMENT'S TEXT when `segment_id` is set — NULL means the whole segment.
603
+ // On a NOTE citation, both NULL means "the note's Context field" (#440): the
604
+ // Context has no body offsets, so the quote alone carries the span.
605
+ // #363 relies on this exact convention; do not reinterpret it per-caller.
606
+ // -----------------------------------------------------------------------------
607
+ model KgEvidence {
608
+ id String @id @default(uuid()) @db.Uuid
609
+ ownerId String @map("owner_id") @db.Uuid
610
+ subjectKind KgEvidenceSubjectKind @map("subject_kind")
611
+ // Polymorphic, no FK — see the block comment above the model.
612
+ subjectId String @map("subject_id") @db.Uuid
613
+
614
+ // Transcript anchor. SetNull — see the section header's "WHY THERE IS NO
615
+ // ANCHOR CHECK" note.
616
+ transcriptId String? @map("transcript_id") @db.Uuid
617
+ segmentId String? @map("segment_id") @db.Uuid
618
+ segmentRev Int? @map("segment_rev")
619
+ startMs Int? @map("start_ms")
620
+ endMs Int? @map("end_ms")
621
+
622
+ // Note anchor. SetNull, same reasoning.
623
+ noteId String? @map("note_id") @db.Uuid
624
+ noteVersion Int? @map("note_version")
625
+
626
+ // Offsets into the cited NOTE VERSION BODY when `noteId` is set, or into
627
+ // the cited SEGMENT'S TEXT when `segmentId` is set. NULL means the whole
628
+ // segment; on a note citation, both NULL means the note's Context field
629
+ // (#440). See the block comment above the model — #363 reads this
630
+ // convention directly.
631
+ charStart Int? @map("char_start")
632
+ charEnd Int? @map("char_end")
633
+
634
+ // The text AT ANCHORING TIME (§5.3) — NOT NULL, deliberately: this is
635
+ // what keeps the citation readable once every anchor above has gone to
636
+ // NULL (see the section header's "WHY THERE IS NO ANCHOR CHECK" note).
637
+ quote String
638
+
639
+ // Import anchor. SetNull, same reasoning.
640
+ importObjectId String? @map("import_object_id") @db.Uuid
641
+ sourceIri String? @map("source_iri")
642
+
643
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
644
+
645
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
646
+ transcript Transcript? @relation(fields: [transcriptId], references: [id], onDelete: SetNull)
647
+ segment TranscriptSegment? @relation(fields: [segmentId], references: [id], onDelete: SetNull)
648
+ note Note? @relation(fields: [noteId], references: [id], onDelete: SetNull)
649
+ importObject StorageObject? @relation(fields: [importObjectId], references: [id], onDelete: SetNull)
650
+
651
+ @@index([subjectKind, subjectId])
652
+ @@index([ownerId, transcriptId])
653
+ @@index([segmentId])
654
+ @@index([ownerId, noteId])
655
+ @@map("kg_evidence")
656
+ }
657
+
658
+ // -----------------------------------------------------------------------------
659
+ // `kg_mentions` — the coarse `MENTIONS` shortcut (§5.2): what an entity
660
+ // page's "notes about Joe" section reads, distinct from `kg_evidence`'s
661
+ // precise per-claim citation. Exactly one source document per row
662
+ // (`kg_mentions_one_source_chk`). `entity_id`/`note_id`/`transcript_id` ALL
663
+ // Cascade — a mention has no meaning once either side is gone, the opposite
664
+ // of `kg_evidence`'s pointer relationship above.
665
+ // -----------------------------------------------------------------------------
666
+ model KgMention {
667
+ id String @id @default(uuid()) @db.Uuid
668
+ ownerId String @map("owner_id") @db.Uuid
669
+
670
+ entityId String @map("entity_id") @db.Uuid
671
+
672
+ noteId String? @map("note_id") @db.Uuid
673
+ transcriptId String? @map("transcript_id") @db.Uuid
674
+
675
+ // `{charStart,charEnd}` or `{segmentId}` — which shape depends on which of
676
+ // `noteId`/`transcriptId` is set.
677
+ span Json? @db.JsonB
678
+ status KgMentionStatus @default(linked)
679
+ score Float?
680
+
681
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
682
+
683
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
684
+ entity KgEntity @relation(fields: [entityId], references: [id], onDelete: Cascade)
685
+ note Note? @relation(fields: [noteId], references: [id], onDelete: Cascade)
686
+ transcript Transcript? @relation(fields: [transcriptId], references: [id], onDelete: Cascade)
687
+
688
+ @@index([ownerId, entityId])
689
+ @@index([noteId])
690
+ @@index([transcriptId])
691
+ // HAND-WRITTEN in migration.sql only: `kg_mentions_one_source_chk`. See
692
+ // the section header's "EIGHT CHECK CONSTRAINTS" note.
693
+ @@map("kg_mentions")
694
+ }
695
+
696
+ // -----------------------------------------------------------------------------
697
+ // `kg_proposals` — one row per extraction, import, or resolution run
698
+ // (§8, §18.3; the complete kind/status enums are declared above THIS
699
+ // migration, issue #351, so #363/#364/#366/#387 never need `ALTER TYPE`).
700
+ // Only `kind = 'extraction'` has a note — `kg_proposals_note_source_chk`
701
+ // requires `note_version` for `extraction` and requires BOTH `note_id` AND
702
+ // `note_version` NULL for `import`/`resolution`. The CHECK keys on
703
+ // `note_version`, never `note_id`: `note_id` is inserted non-null for every
704
+ // extraction and can only become NULL afterwards via the note's own
705
+ // `SetNull` FK once the note is hard-deleted, so a CHECK on `note_id` would
706
+ // start failing for rows that were valid when written. `commit_log` is
707
+ // #366's internal undo record — the commit writes it, the revert reads it —
708
+ // and it is NEVER serialized to clients; `stats` is the display copy.
709
+ // `job_id` mirrors `TranscriptExport.jobId` exactly (`@unique`, nullable,
710
+ // SetNull): a proposal's own review lifetime is independent of
711
+ // `job.history.purge`'s schedule for the job row.
712
+ // -----------------------------------------------------------------------------
713
+ model KgProposal {
714
+ id String @id @default(uuid()) @db.Uuid
715
+ ownerId String @map("owner_id") @db.Uuid
716
+ kind KgProposalKind
717
+ status KgProposalStatus @default(draft)
718
+
719
+ // Only `kind = 'extraction'` has a note. See the block comment above the
720
+ // model for why `kg_proposals_note_source_chk` keys on `noteVersion`.
721
+ noteId String? @map("note_id") @db.Uuid
722
+ noteVersion Int? @map("note_version")
723
+
724
+ // Generation context — which task-model resolution actually ran (§20),
725
+ // recorded rather than re-derived, so an administrator changing the
726
+ // default tomorrow never rewrites what an already-committed proposal used
727
+ // yesterday.
728
+ model String?
729
+ provider String?
730
+ systemPrompt String? @map("system_prompt")
731
+ userContent String? @map("user_content")
732
+ userGuidance Json? @map("user_guidance") @db.JsonB
733
+
734
+ stats Json @default("{}") @db.JsonB
735
+
736
+ committedAt DateTime? @map("committed_at") @db.Timestamptz
737
+ revertedAt DateTime? @map("reverted_at") @db.Timestamptz
738
+ jobId String? @unique @map("job_id") @db.Uuid
739
+
740
+ // #366's internal undo record — the commit writes it, the revert reads
741
+ // it. NEVER serialized to clients; `stats` above is the display copy.
742
+ commitLog Json? @map("commit_log") @db.JsonB
743
+
744
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
745
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
746
+
747
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
748
+ note Note? @relation(fields: [noteId], references: [id], onDelete: SetNull)
749
+ job Job? @relation(fields: [jobId], references: [id], onDelete: SetNull)
750
+
751
+ items KgProposalItem[]
752
+
753
+ @@index([ownerId, status, createdAt(sort: Desc)])
754
+ @@index([noteId, createdAt(sort: Desc)])
755
+ // HAND-WRITTEN in migration.sql only: `kg_proposals_note_draft_uniq_idx`,
756
+ // `kg_proposals_note_extracting_uniq_idx`,
757
+ // `kg_proposals_owner_import_extracting_uniq_idx` (three partial unique
758
+ // indexes) and `kg_proposals_note_source_chk`. See the section header's
759
+ // "FIVE PARTIAL UNIQUE INDEXES" / "EIGHT CHECK CONSTRAINTS" notes.
760
+ @@map("kg_proposals")
761
+ }
762
+
763
+ // -----------------------------------------------------------------------------
764
+ // `kg_proposal_items` — one row per proposed entity, relation, item, or
765
+ // closing edit (`closing` is §7's "closing a temporal edge is a proposal
766
+ // row" case). `merge_into_id`/`distinct_from` are #366's reviewer-override
767
+ // columns, declared up front by this migration (issue #351) so #366 needs
768
+ // no schema change of its own: `merge_into_id` is set IFF `decision =
769
+ // 'merge_into'` (`kg_proposal_items_merge_into_chk`), SetNull so losing the
770
+ // target entity clears the override rather than blocking the delete;
771
+ // `distinct_from` records the "not the same as" candidates a reviewer ruled
772
+ // out, as plain uuids (no FK — these are recorded facts about a review
773
+ // decision, not live references another table's delete must cascade
774
+ // through). `committed_ref_id` is polymorphic (no FK), the row this item
775
+ // became once committed — #366's revert reads this to know exactly what to
776
+ // undo.
777
+ // -----------------------------------------------------------------------------
778
+ model KgProposalItem {
779
+ id String @id @default(uuid()) @db.Uuid
780
+ proposalId String @map("proposal_id") @db.Uuid
781
+ kind KgProposalItemKind
782
+
783
+ payload Json @db.JsonB
784
+ resolution Json? @db.JsonB
785
+ editedPayload Json? @map("edited_payload") @db.JsonB
786
+
787
+ decision KgProposalItemDecision @default(pending)
788
+ // Values from #363's `PROPOSAL_ITEM_FLAGS` — plain text[], not an enum:
789
+ // the review panel's pre-check flags are a growing, code-owned set, the
790
+ // same reasoning `kg_entities.type` stays plain text.
791
+ flags String[] @default([])
792
+ origin KgProposalItemOrigin @default(ai)
793
+
794
+ // #366's reviewer-override columns. See the block comment above the model
795
+ // for the exactly-one-decision argument `kg_proposal_items_merge_into_chk`
796
+ // enforces, and why `mergeIntoId` is SetNull.
797
+ mergeIntoId String? @map("merge_into_id") @db.Uuid
798
+ distinctFrom String[] @default([]) @map("distinct_from")
799
+
800
+ // Polymorphic, no FK — the `kg_entities`/`kg_relations`/`kg_items` row
801
+ // this item became once committed.
802
+ committedRefId String? @map("committed_ref_id") @db.Uuid
803
+ sortOrder Int @default(0) @map("sort_order")
804
+
805
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
806
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
807
+
808
+ proposal KgProposal @relation(fields: [proposalId], references: [id], onDelete: Cascade)
809
+ mergeInto KgEntity? @relation("KgProposalItemMergeInto", fields: [mergeIntoId], references: [id], onDelete: SetNull)
810
+
811
+ @@index([proposalId, kind, sortOrder])
812
+ // HAND-WRITTEN in migration.sql only: `kg_proposal_items_kind_payload_chk`
813
+ // and `kg_proposal_items_merge_into_chk`. See the section header's "EIGHT
814
+ // CHECK CONSTRAINTS" note.
815
+ @@map("kg_proposal_items")
816
+ }
817
+
818
+ // One row per merge (§7), with the full reversal payload — what #366 (and
819
+ // an entity page's "undo merge" action) replays to restore the merged
820
+ // entity. `survivor_id`/`merged_id` both Cascade: a merge record has no
821
+ // meaning once either entity it names is gone.
822
+ model KgMerge {
823
+ id String @id @default(uuid()) @db.Uuid
824
+ ownerId String @map("owner_id") @db.Uuid
825
+ survivorId String @map("survivor_id") @db.Uuid
826
+ mergedId String @map("merged_id") @db.Uuid
827
+
828
+ reversal Json @db.JsonB
829
+ reversedAt DateTime? @map("reversed_at") @db.Timestamptz
830
+
831
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
832
+
833
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
834
+ survivor KgEntity @relation("KgMergeSurvivor", fields: [survivorId], references: [id], onDelete: Cascade)
835
+ merged KgEntity @relation("KgMergeMerged", fields: [mergedId], references: [id], onDelete: Cascade)
836
+
837
+ @@index([ownerId, createdAt(sort: Desc)])
838
+ @@index([survivorId])
839
+ @@index([mergedId])
840
+ @@map("kg_merges")
841
+ }
842
+
843
+ // Confirmed-NOT-the-same pairs (§7), skipped by every future
844
+ // candidate-generation pass. `a_id < b_id` is enforced by
845
+ // `kg_distinct_pairs_order_chk` (hand-written — Prisma cannot express a
846
+ // cross-column CHECK), so `(a, b)` and `(b, a)` can never both exist as two
847
+ // different rows for what is the same unordered fact. Both entity FKs
848
+ // Cascade.
849
+ model KgDistinctPair {
850
+ ownerId String @map("owner_id") @db.Uuid
851
+ aId String @map("a_id") @db.Uuid
852
+ bId String @map("b_id") @db.Uuid
853
+
854
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
855
+
856
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
857
+ entityA KgEntity @relation("KgDistinctPairA", fields: [aId], references: [id], onDelete: Cascade)
858
+ entityB KgEntity @relation("KgDistinctPairB", fields: [bId], references: [id], onDelete: Cascade)
859
+
860
+ @@id([ownerId, aId, bId])
861
+ // HAND-WRITTEN in migration.sql only: `kg_distinct_pairs_order_chk`. See
862
+ // the section header's "EIGHT CHECK CONSTRAINTS" note.
863
+ @@map("kg_distinct_pairs")
864
+ }
865
+
866
+ // One row per user-defined attribute (§17.3) — the schema half of the
867
+ // ontology's user-extensibility story. `owner_id` Cascade, the same
868
+ // reasoning every other `kg_*` table's `owner_id` follows.
869
+ model KgAttributeDef {
870
+ id String @id @default(uuid()) @db.Uuid
871
+ ownerId String @map("owner_id") @db.Uuid
872
+
873
+ entityType String @map("entity_type")
874
+ key String
875
+ label String
876
+ kind KgAttributeKind
877
+ options Json? @db.JsonB
878
+
879
+ extractable Boolean @default(false)
880
+ extractionHint String? @map("extraction_hint")
881
+
882
+ sensitivity KgSensitivity?
883
+ sortOrder Int @default(0) @map("sort_order")
884
+ deprecatedAt DateTime? @map("deprecated_at") @db.Timestamptz
885
+
886
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz
887
+ updatedAt DateTime @updatedAt @map("updated_at") @db.Timestamptz
888
+
889
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
890
+
891
+ @@unique([ownerId, entityType, key])
892
+ @@index([ownerId, entityType, sortOrder])
893
+ @@map("kg_attribute_defs")
894
+ }
895
+
896
+ // §9.2's precomputed entity brief — "precompute once, read cheaply," the
897
+ // same economy `kg_graph_layouts` (its own, later migration) applies to a
898
+ // whole-graph layout. `entity_id` is the PRIMARY KEY (not a separate `id`):
899
+ // there is exactly one digest per entity, always replaced in place, never
900
+ // versioned.
901
+ model KgEntityDigest {
902
+ entityId String @id @map("entity_id") @db.Uuid
903
+ ownerId String @map("owner_id") @db.Uuid
904
+
905
+ summary String
906
+ citations Json @db.JsonB
907
+ coversUntil DateTime @map("covers_until") @db.Timestamptz
908
+ model String
909
+ generatedAt DateTime @map("generated_at") @db.Timestamptz
910
+
911
+ entity KgEntity @relation(fields: [entityId], references: [id], onDelete: Cascade)
912
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
913
+
914
+ @@index([ownerId])
915
+ @@map("kg_entity_digests")
916
+ }
917
+
918
+ // (user_id, entity_id, last_viewed_at) — §9.2's "recently viewed" list. The
919
+ // ONE table in this section keyed on `user_id`, not `owner_id` — see
920
+ // convention 1 above: which entities a viewer has looked at is a per-viewer
921
+ // fact, not an ownership fact (a shared/admin view is out of scope today,
922
+ // but the column name is deliberately not `owner_id` so it never implies
923
+ // one). A plain `@@unique`, exactly as issue #351's canonical column list
924
+ // specifies — NOT the partial unique §10's prose mentions elsewhere;
925
+ // nothing about this row is conditional.
926
+ model KgEntityView {
927
+ id String @id @default(uuid()) @db.Uuid
928
+ userId String @map("user_id") @db.Uuid
929
+ entityId String @map("entity_id") @db.Uuid
930
+ lastViewedAt DateTime @map("last_viewed_at") @db.Timestamptz
931
+
932
+ user User @relation(fields: [userId], references: [id], onDelete: Cascade)
933
+ entity KgEntity @relation(fields: [entityId], references: [id], onDelete: Cascade)
934
+
935
+ @@unique([userId, entityId])
936
+ @@map("kg_entity_views")
937
+ }
938
+
939
+ // =============================================================================
940
+ // `kg_exports` — RDF exports of one owner's graph (issue #386, epic #349;
941
+ // docs/specs/ontology.md §18.2)
942
+ // =============================================================================
943
+ //
944
+ // Mirrors `note_exports`: one row per requested render, produced by the
945
+ // `kg.export` job into a `managed_by: 'graph'` storage object, expiring 7 days
946
+ // after it was requested (the daily sweep deletes the row and the file).
947
+ //
948
+ // CONTENT-ADDRESSED on `graph_fingerprint` — a SHA-256 over the ontology
949
+ // version, the format, and the owner's graph "shape" (max updated_at plus row
950
+ // counts of the kg_* tables). `POST /api/graph/exports` reuses an unexpired,
951
+ // non-failed row with the same `(owner, format, fingerprint)`, so asking twice
952
+ // for an unchanged graph renders once. A plain, NON-unique index — an expired
953
+ // or failed row may briefly coexist with a fresh one sharing the key, exactly
954
+ // as `note_exports_lookup_idx` allows.
955
+ //
956
+ // `owner_id` Cascade; `object_id` Restrict (the file goes only after the row
957
+ // lets go of it); `job_id` `@unique`/SetNull (the `NoteExport.jobId` shape).
958
+ // No hand-written SQL: every constraint here is Prisma-expressible.
959
+ // =============================================================================
960
+
961
+ enum KgExportFormat {
962
+ jsonld
963
+ turtle
964
+ nquads
965
+
966
+ @@map("kg_export_format")
967
+ }
968
+
969
+ enum KgExportStatus {
970
+ pending
971
+ running
972
+ ready
973
+ failed
974
+
975
+ @@map("kg_export_status")
976
+ }
977
+
978
+ model KgExport {
979
+ id String @id @default(uuid()) @db.Uuid
980
+ ownerId String @map("owner_id") @db.Uuid
981
+ format KgExportFormat
982
+ status KgExportStatus @default(pending)
983
+ graphFingerprint String @map("graph_fingerprint")
984
+ ontologyVersion String @map("ontology_version")
985
+ objectId String? @map("object_id") @db.Uuid
986
+ // `{ entities, relations, items, evidence, excludedSensitive, bytes }` — counts only.
987
+ stats Json @default("{}") @db.JsonB
988
+ // Short and generic; never row data.
989
+ errorMessage String? @map("error_message")
990
+ jobId String? @unique @map("job_id") @db.Uuid
991
+ expiresAt DateTime @map("expires_at") @db.Timestamptz(6)
992
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
993
+
994
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
995
+ object StorageObject? @relation(fields: [objectId], references: [id], onDelete: Restrict)
996
+ job Job? @relation(fields: [jobId], references: [id], onDelete: SetNull)
997
+
998
+ @@index([ownerId, format, graphFingerprint])
999
+ @@index([expiresAt])
1000
+ @@map("kg_exports")
1001
+ }
1002
+
1003
+ /// Latest whole-graph layout per owner (spec §22, issue #371). A cache of derived data — never
1004
+ /// a source of truth; labels are NOT stored (read live, so a renamed or forgotten entity never
1005
+ /// shows a stale label). `clusters`/`positions` are versioned JSON blobs (`{ version: 1, ... }`)
1006
+ /// rather than typed columns, produced by the pure `computeLayout()` function and consumed only
1007
+ /// by `GraphOverviewService`, which joins live entity rows back in at read time.
1008
+ model KgGraphLayout {
1009
+ id String @id @default(uuid()) @db.Uuid
1010
+ ownerId String @map("owner_id") @db.Uuid
1011
+
1012
+ computedAt DateTime @map("computed_at") @db.Timestamptz(6)
1013
+
1014
+ nodeCount Int @map("node_count")
1015
+ edgeCount Int @map("edge_count")
1016
+
1017
+ clusters Json @db.JsonB
1018
+ positions Json @db.JsonB
1019
+
1020
+ ontologyVersion String @map("ontology_version")
1021
+
1022
+ // max(updated_at) over the owner's kg_entities/kg_relations read by this run — the staleness
1023
+ // check `GET /api/graph/overview` compares against a fresh count, never recomputing itself.
1024
+ sourceUpdatedAt DateTime? @map("source_updated_at") @db.Timestamptz(6)
1025
+
1026
+ owner User @relation(fields: [ownerId], references: [id], onDelete: Cascade)
1027
+
1028
+ @@index([ownerId, computedAt(sort: Desc)])
1029
+ @@map("kg_graph_layouts")
1030
+ }