spine-framework 0.3.107 → 0.3.109

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.
@@ -184,16 +184,26 @@ export async function runAgent(
184
184
  )
185
185
  }
186
186
 
187
- // 5. Save agent response
187
+ // 5. Save agent response — include retrieved_sources + retrieval_confidence
188
+ // so the feedback capture pipeline and the Portal UI can access them.
188
189
  const agentMsg = await saveMessage(
189
190
  threadId,
190
191
  agentResponse.content,
191
192
  'agent',
192
193
  {
193
194
  confidence: agentResponse.confidence,
195
+ retrieval_confidence: agentResponse.retrievalConfidence,
196
+ retrieved_sources: (agentResponse.retrievedSources || []).map((s: any) => ({
197
+ document_id: s.document_id,
198
+ title: s.title,
199
+ similarity: s.similarity,
200
+ item_type: s.item_type,
201
+ vector_type: s.vector_type,
202
+ })),
194
203
  tool_calls: agentResponse.tool_calls,
195
204
  agent_id: agent.id,
196
- prompt_config_id: promptConfig.id
205
+ prompt_config_id: promptConfig.id,
206
+ sender_type: 'agent',
197
207
  },
198
208
  ctx,
199
209
  replyTypeSlug
@@ -208,6 +218,8 @@ export async function runAgent(
208
218
  thread_id: threadId,
209
219
  agent_id: agent.id,
210
220
  confidence: agentResponse.confidence,
221
+ retrieval_confidence: agentResponse.retrievalConfidence,
222
+ sources_count: agentResponse.retrievedSources?.length || 0,
211
223
  has_tool_calls: !!agentResponse.tool_calls?.length,
212
224
  duration_ms: Date.now() - startTime
213
225
  })
@@ -347,36 +359,49 @@ async function executeAgentInference(
347
359
  userMessage: string,
348
360
  ctx: CoreContext,
349
361
  maxToolIterations: number = 5
350
- ): Promise<InferenceResult> {
351
- const { agent, promptConfig, thread } = config
362
+ ): Promise<InferenceResult & { retrievedSources: any[]; retrievalConfidence: number }> {
363
+ const { agent, promptConfig } = config
352
364
 
353
- // Build initial context
354
- let context = await buildContext(config, userMessage, [], ctx)
365
+ // Build initial context — captures retrieved sources for feedback capture
366
+ let { context, retrievedSources, meanSimilarity } = await buildContext(config, userMessage, [], ctx)
355
367
 
356
368
  for (let iteration = 0; iteration < maxToolIterations; iteration++) {
357
- // Call inference
358
369
  const inferenceResult = await callInference(context, agent, promptConfig, ctx)
359
370
 
360
- // If no tool calls, return immediately
361
371
  if (!inferenceResult.tool_calls || inferenceResult.tool_calls.length === 0) {
362
- return inferenceResult
372
+ // Composite confidence: 60% LLM logprobs + 40% retrieval similarity
373
+ // If mean retrieval similarity < 0.40, cap composite at 0.60
374
+ const llmConf = inferenceResult.confidence ?? 0.85
375
+ let compositeConf: number
376
+ if (meanSimilarity < 0.40 && meanSimilarity > 0) {
377
+ compositeConf = Math.min(llmConf * 0.6 + meanSimilarity * 0.4, 0.60)
378
+ } else {
379
+ compositeConf = llmConf * 0.6 + Math.min(meanSimilarity, 1) * 0.4
380
+ }
381
+
382
+ return {
383
+ ...inferenceResult,
384
+ confidence: Math.min(Math.max(compositeConf, 0), 1),
385
+ retrievedSources,
386
+ retrievalConfidence: meanSimilarity,
387
+ }
363
388
  }
364
389
 
365
- // Execute tools
366
390
  const toolResults = await dispatchTools(inferenceResult.tool_calls, ctx)
391
+ const rebuilt = await buildContext(config, userMessage, toolResults, ctx)
392
+ context = rebuilt.context
393
+ if (rebuilt.retrievedSources.length) {
394
+ retrievedSources = rebuilt.retrievedSources
395
+ meanSimilarity = rebuilt.meanSimilarity
396
+ }
367
397
 
368
- // Add tool results to context and re-inference
369
- context = await buildContext(config, userMessage, toolResults, ctx)
370
-
371
- // Check if any tool failed - if so, return with error info
372
398
  const hasErrors = toolResults.some(r => r.error)
373
399
  if (hasErrors && iteration === maxToolIterations - 1) {
374
400
  inferenceResult.content += '\n\n[Note: Some tools failed to execute.]'
375
- return inferenceResult
401
+ return { ...inferenceResult, retrievedSources, retrievalConfidence: meanSimilarity }
376
402
  }
377
403
  }
378
404
 
379
- // Max iterations reached
380
405
  throw new Error('Max tool iterations reached')
381
406
  }
382
407
 
@@ -407,7 +432,7 @@ async function buildContext(
407
432
  userMessage: string,
408
433
  toolResults: ToolResult[],
409
434
  ctx: CoreContext
410
- ): Promise<string> {
435
+ ): Promise<{ context: string; retrievedSources: any[]; meanSimilarity: number }> {
411
436
  const { agent, promptConfig, thread } = config
412
437
 
413
438
  // 1. System prompt (from agent)
@@ -419,19 +444,30 @@ async function buildContext(
419
444
  }
420
445
 
421
446
  // 3. Retrieved knowledge (RAG)
422
- if (promptConfig.knowledge_sources && promptConfig.knowledge_sources.length > 0) {
423
- const retrievedDocs = await retrieveKnowledge(
447
+ // Always attempt retrieval — even with empty knowledge_sources (full scope search)
448
+ let retrievedSources: any[] = []
449
+ let meanSimilarity = 0
450
+ const retrievalPolicy = promptConfig.metadata?.retrieval_policy || {}
451
+
452
+ try {
453
+ const { docs, meanSimilarity: ms } = await retrieveKnowledge(
424
454
  userMessage,
425
- promptConfig.knowledge_sources,
426
- ctx.accountId!
455
+ promptConfig.knowledge_sources || [],
456
+ ctx.accountId!,
457
+ retrievalPolicy
427
458
  )
428
- if (retrievedDocs.length > 0) {
459
+ retrievedSources = docs
460
+ meanSimilarity = ms
461
+
462
+ if (docs.length > 0) {
429
463
  context += '## Relevant Information\n'
430
- retrievedDocs.forEach((doc, i) => {
431
- context += `[${i + 1}] ${doc.content}\n`
464
+ docs.forEach((doc, i) => {
465
+ const sourceLabel = doc.title ? `[${i + 1}] ${doc.title}` : `[${i + 1}]`
466
+ context += `${sourceLabel}\n${doc.content}\n\n`
432
467
  })
433
- context += '\n'
434
468
  }
469
+ } catch (err) {
470
+ console.warn('retrieveKnowledge failed — proceeding without context:', err)
435
471
  }
436
472
 
437
473
  // 4. Conversation history
@@ -470,7 +506,7 @@ async function buildContext(
470
506
  // 7. Current user message
471
507
  context += `## Current Message\nUser: ${userMessage}\n\nAssistant: `
472
508
 
473
- return context
509
+ return { context, retrievedSources, meanSimilarity }
474
510
  }
475
511
 
476
512
  // ─── KNOWLEDGE & HISTORY ───────────────────────────────────────────────────────────
@@ -505,29 +541,35 @@ async function buildContext(
505
541
  async function retrieveKnowledge(
506
542
  query: string,
507
543
  knowledgeSources: string[],
508
- accountId: string
509
- ): Promise<any[]> {
544
+ accountId: string,
545
+ retrievalPolicy?: {
546
+ item_types?: string[]
547
+ vector_types?: string[]
548
+ max_security_level?: 'external' | 'internal' | 'restricted'
549
+ require_approved?: boolean
550
+ }
551
+ ): Promise<{ docs: any[]; meanSimilarity: number }> {
510
552
  const SIMILARITY_THRESHOLD = 0.15
511
553
  const MATCH_COUNT = 8
554
+ const policy = retrievalPolicy || {}
555
+ const vectorTypesToSearch = policy.vector_types?.length ? policy.vector_types : ['semantic']
556
+ const maxSecurityLevel = policy.max_security_level || 'internal'
557
+
558
+ // Security level ordering: external < internal < restricted
559
+ const SECURITY_ORDER: Record<string, number> = { external: 0, internal: 1, restricted: 2 }
560
+ const maxSecurityRank = SECURITY_ORDER[maxSecurityLevel] ?? 1
512
561
 
513
562
  // ── Tier 1: vector similarity via match_embeddings RPC ──────────────────
514
563
  try {
515
564
  const apiKey = process.env.OPENAI_API_KEY || process.env.LLM_API_KEY
516
565
  const baseUrl = process.env.OPENAI_BASE_URL || 'https://api.openai.com/v1'
517
-
518
566
  if (!apiKey) throw new Error('no_api_key')
519
567
 
520
- // Resolve platform account ID at runtime — no hardcoded UUIDs
568
+ // Resolve platform account — always include spine-system KB
521
569
  const { data: platformAccount } = await adminDb
522
- .from('accounts')
523
- .select('id')
524
- .eq('slug', 'spine-system')
525
- .maybeSingle()
526
-
570
+ .from('accounts').select('id').eq('slug', 'spine-system').maybeSingle()
527
571
  const accountIds: string[] = [accountId]
528
- if (platformAccount?.id && platformAccount.id !== accountId) {
529
- accountIds.push(platformAccount.id)
530
- }
572
+ if (platformAccount?.id && platformAccount.id !== accountId) accountIds.push(platformAccount.id)
531
573
 
532
574
  // Generate query embedding
533
575
  const embedRes = await fetch(`${baseUrl}/embeddings`, {
@@ -540,29 +582,37 @@ async function retrieveKnowledge(
540
582
  const queryEmbedding: number[] = embedJson.data?.[0]?.embedding || []
541
583
  if (!queryEmbedding.length) throw new Error('empty_embedding')
542
584
 
543
- // Call vector RPC
544
- const { data: matches, error: rpcError } = await adminDb.rpc('match_embeddings', {
545
- query_embedding: `[${queryEmbedding.join(',')}]`,
546
- match_count: MATCH_COUNT * 3,
547
- filter_account_ids: accountIds,
548
- filter_vector_type: 'semantic',
549
- similarity_threshold: SIMILARITY_THRESHOLD,
550
- })
551
- if (rpcError) throw new Error(`rpc_${rpcError.message}`)
585
+ // Multi-vector search: run all declared vector types and merge
586
+ const allMatches: any[] = []
587
+ for (const vt of vectorTypesToSearch) {
588
+ const { data: matches, error: rpcError } = await adminDb.rpc('match_embeddings', {
589
+ query_embedding: `[${queryEmbedding.join(',')}]`,
590
+ match_count: MATCH_COUNT * 3,
591
+ filter_account_ids: accountIds,
592
+ filter_vector_type: vt,
593
+ similarity_threshold: SIMILARITY_THRESHOLD,
594
+ filter_item_types: policy.item_types?.length ? policy.item_types : null,
595
+ })
596
+ if (rpcError) throw new Error(`rpc_${rpcError.message}`)
597
+ allMatches.push(...(matches || []))
598
+ }
552
599
 
553
- // Filter: security_level restriction for cross-account results
554
- const filtered = (matches || []).filter((r: any) => {
600
+ // Hard security filter
601
+ const filtered = allMatches.filter((r: any) => {
555
602
  if (r.similarity < SIMILARITY_THRESHOLD) return false
556
- if (r.account_id === accountId) return true
557
- return r.metadata?.security_level !== 'restricted'
603
+ const secLevel = r.metadata?.security_level || 'internal'
604
+ const rank = SECURITY_ORDER[secLevel] ?? 1
605
+ if (rank > maxSecurityRank) return false
606
+ if (r.account_id !== accountId && secLevel === 'restricted') return false
607
+ return true
558
608
  })
559
609
 
560
- // Optionally narrow to knowledgeSources doc IDs
610
+ // Narrow to knowledgeSources if specified
561
611
  const scoped = knowledgeSources.length > 0
562
612
  ? filtered.filter((r: any) => knowledgeSources.includes(r.document_id))
563
613
  : filtered
564
614
 
565
- // Deduplicate — best chunk per document
615
+ // Deduplicate — best chunk per document across all vector types
566
616
  const bestByDoc = new Map<string, any>()
567
617
  for (const r of scoped) {
568
618
  const existing = bestByDoc.get(r.document_id)
@@ -572,47 +622,66 @@ async function retrieveKnowledge(
572
622
  .sort((a, b) => b.similarity - a.similarity)
573
623
  .slice(0, MATCH_COUNT)
574
624
 
575
- if (deduped.length === 0) return []
625
+ if (!deduped.length) return { docs: [], meanSimilarity: 0 }
626
+
627
+ // If top result < 0.50 and summary not already included, try summary fallback
628
+ if (deduped[0]?.similarity < 0.50 && !vectorTypesToSearch.includes('summary')) {
629
+ const { data: summaryMatches } = await adminDb.rpc('match_embeddings', {
630
+ query_embedding: `[${queryEmbedding.join(',')}]`,
631
+ match_count: MATCH_COUNT,
632
+ filter_account_ids: accountIds,
633
+ filter_vector_type: 'summary',
634
+ similarity_threshold: SIMILARITY_THRESHOLD,
635
+ filter_item_types: policy.item_types?.length ? policy.item_types : null,
636
+ })
637
+ for (const r of summaryMatches || []) {
638
+ if (!bestByDoc.has(r.document_id) && r.similarity >= SIMILARITY_THRESHOLD) {
639
+ bestByDoc.set(r.document_id, r)
640
+ }
641
+ }
642
+ }
643
+ const finalDocs = [...bestByDoc.values()]
644
+ .sort((a, b) => b.similarity - a.similarity)
645
+ .slice(0, MATCH_COUNT)
576
646
 
577
647
  // Enrich with item titles
578
648
  const { data: items } = await adminDb
579
- .from('items')
580
- .select('id, title, description')
581
- .in('id', deduped.map((r: any) => r.document_id))
582
-
649
+ .from('items').select('id, title').in('id', finalDocs.map((r: any) => r.document_id))
583
650
  const itemMap = new Map((items || []).map((i: any) => [i.id, i]))
584
- return deduped.map((r: any) => ({
651
+
652
+ const docs = finalDocs.map((r: any) => ({
585
653
  document_id: r.document_id,
586
654
  content: r.content,
587
655
  metadata: r.metadata,
588
656
  similarity: r.similarity,
589
657
  title: itemMap.get(r.document_id)?.title || '',
658
+ item_type: r.metadata?.item_type || '',
659
+ vector_type: r.metadata?.vector_type || '',
590
660
  }))
591
661
 
662
+ const meanSimilarity = docs.length
663
+ ? docs.reduce((sum: number, d: any) => sum + d.similarity, 0) / docs.length
664
+ : 0
665
+
666
+ return { docs, meanSimilarity }
667
+
592
668
  } catch (vectorErr: any) {
593
669
  // ── Tier 2: full-text fallback ──────────────────────────────────────
594
670
  if (vectorErr.message !== 'no_api_key') {
595
671
  console.warn('retrieveKnowledge vector search failed, falling back to text search:', vectorErr.message)
596
672
  }
597
-
598
673
  const baseQuery = adminDb
599
674
  .from('embeddings')
600
675
  .select('document_id, content, metadata, account_id')
601
676
  .eq('account_id', accountId)
602
677
  .eq('metadata->>vector_type', 'semantic')
678
+ .not('revoked_at', 'is', null)
603
679
  .textSearch('content', query, { type: 'websearch', config: 'english' })
604
680
  .limit(5)
605
-
606
- const q = knowledgeSources.length > 0
607
- ? baseQuery.in('document_id', knowledgeSources)
608
- : baseQuery
609
-
681
+ const q = knowledgeSources.length > 0 ? baseQuery.in('document_id', knowledgeSources) : baseQuery
610
682
  const { data: docs, error } = await q
611
- if (error) {
612
- console.error('Knowledge retrieval fallback failed:', error)
613
- return []
614
- }
615
- return docs || []
683
+ if (error) { console.error('Knowledge retrieval fallback failed:', error); return { docs: [], meanSimilarity: 0 } }
684
+ return { docs: docs || [], meanSimilarity: 0 }
616
685
  }
617
686
  }
618
687
 
@@ -1172,17 +1241,6 @@ async function handleEscalation(
1172
1241
  * @sideEffects DB write: messages table
1173
1242
  * @calledBy runAgent (user message + agent response)
1174
1243
  */
1175
- // Module-level cache so we only look up message type_ids once per cold-start
1176
- const _messageTypeIds: Record<string, string> = {}
1177
-
1178
- async function getMessageTypeId(slug: string): Promise<string> {
1179
- if (_messageTypeIds[slug]) return _messageTypeIds[slug]
1180
- const { data } = await adminDb.from('types').select('id').eq('slug', slug).maybeSingle()
1181
- if (!data?.id) throw new Error(`Type "${slug}" not found in types table`)
1182
- _messageTypeIds[slug] = data.id
1183
- return data.id
1184
- }
1185
-
1186
1244
  // Map a thread type slug to the corresponding message/reply type slug.
1187
1245
  function getReplyTypeSlug(threadTypeSlug: string): string {
1188
1246
  if (threadTypeSlug === 'support_thread') return 'support_reply'
@@ -1198,20 +1256,20 @@ async function saveMessage(
1198
1256
  ctx: CoreContext,
1199
1257
  messageTypeSlug: string = 'message'
1200
1258
  ): Promise<any> {
1201
- const [typeId, { count }] = await Promise.all([
1202
- getMessageTypeId(messageTypeSlug),
1203
- adminDb.from('messages').select('id', { count: 'exact', head: true }).eq('thread_id', threadId),
1204
- ])
1259
+ const { count } = await adminDb
1260
+ .from('messages')
1261
+ .select('id', { count: 'exact', head: true })
1262
+ .eq('thread_id', threadId)
1205
1263
  const sequence = (count ?? 0) + 1
1206
1264
  const direction = messageType === 'human' ? 'inbound' : 'outbound'
1207
1265
  // human + agent messages are customer-visible; tool/system messages are internal
1208
1266
  const visibility = (messageType === 'human' || messageType === 'agent') ? 'external' : 'internal'
1209
1267
 
1210
- // Use admin-data's create() so design_schema + validation_schema are stamped
1211
- // from the type record — ensuring the permission engine returns full rows.
1268
+ // Use admin-data's create() so type_slug is resolved server-side and
1269
+ // design_schema + validation_schema are stamped from the type record.
1212
1270
  const message = await createRecord(ctx, {
1213
1271
  entity: 'messages',
1214
- type_id: typeId,
1272
+ type_slug: messageTypeSlug,
1215
1273
  thread_id: threadId,
1216
1274
  person_id: messageType === 'human' ? ctx.principal?.id : null,
1217
1275
  content,
@@ -252,22 +252,10 @@ class _PermissionEngineInternal {
252
252
  // 1. Load type design schema if not provided (pre-stamped on record is preferred)
253
253
  let schema = designSchema
254
254
  if (!schema || !schema.record_permissions) {
255
- // Attempt type lookup by slug as fallback
256
- const { data: typeRecord } = await adminDb
257
- .from('types')
258
- .select('design_schema')
259
- .eq('slug', typeSlug)
260
- .eq('is_active', true)
261
- .single()
262
-
263
- // No schema = no permissions. RLS controls row access;
264
- // design_schema controls what the principal can do with the record.
265
- // A missing or empty schema is an explicit deny — not a free pass.
266
- if (!typeRecord?.design_schema?.record_permissions) {
267
- return defaultResult
268
- }
269
-
270
- schema = typeRecord.design_schema
255
+ // Runtime records are stamped with design_schema at creation. We do not
256
+ // fall back to the type definition because the runtime record is the
257
+ // source of truth for its own schema and permissions.
258
+ return defaultResult
271
259
  }
272
260
 
273
261
  // 2. Get user's role via people.role_id FK
@@ -626,7 +626,37 @@ const stageHandlers: Record<string, Function> = {
626
626
  metadata: { mock: true },
627
627
  model: model || 'gpt-4o'
628
628
  }
629
- }
629
+ },
630
+
631
+ // ── generate_embeddings ─────────────────────────────────────────────────────
632
+ // Generates embeddings for an item using the core embeddings.generate action.
633
+ // Supports {{trigger.entity_id}} template interpolation in config.item_id.
634
+ //
635
+ // Config: item_id (required — supports {{trigger.entity_id}}),
636
+ // force_regenerate (bool, default false)
637
+ //
638
+ // Called by the auto_embed_on_publish and auto_revoke_on_unpublish pipelines.
639
+ 'generate_embeddings': async (ctx: CoreContext, config: any) => {
640
+ const triggerData = config._triggerData || {}
641
+ let itemId = config.item_id
642
+ if (!itemId) throw new Error('item_id is required for generate_embeddings')
643
+
644
+ // Template interpolation: {{trigger.entity_id}} or {{entity_id}}
645
+ if (typeof itemId === 'string' && itemId.includes('{{')) {
646
+ itemId = itemId
647
+ .replace(/\{\{trigger\.entity_id\}\}/g, triggerData?.entity_id || '')
648
+ .replace(/\{\{entity_id\}\}/g, triggerData?.entity_id || '')
649
+ }
650
+
651
+ if (!itemId) throw new Error('generate_embeddings: could not resolve item_id from trigger data')
652
+
653
+ const { generate } = await import('../embeddings')
654
+ const genCtx = { ...ctx, query: { ...(ctx.query || {}), action: 'generate' } }
655
+ return await generate(genCtx, {
656
+ item_id: itemId,
657
+ force_regenerate: config.force_regenerate ?? false,
658
+ })
659
+ },
630
660
  }
631
661
 
632
662
  // ─── MODULE DISPATCHERS ────────────────────────────────────────────────────────────
@@ -44,6 +44,7 @@
44
44
  import { createHandler } from './_shared/middleware'
45
45
  import { sanitizeRecordData } from './_shared/permissions'
46
46
  import { adminDb } from './_shared/db'
47
+ import { resolveTypeId } from './_shared/resolve-ids'
47
48
  import { fireCreateTriggers, fireUpdateTriggers, fireDeleteTriggers } from './_shared/trigger-engine'
48
49
 
49
50
  // ─── CONSTANTS ────────────────────────────────────────────────────────────────
@@ -59,6 +60,17 @@ const PERMISSIONS_ALL = {
59
60
  */
60
61
  const VALID_ENTITIES = ['accounts', 'people', 'items', 'threads', 'messages', 'links', 'attachments', 'watchers', 'item_progress']
61
62
 
63
+ // Map admin-data entity names to the kind field used in the types table
64
+ const ENTITY_TO_KIND: Record<string, string> = {
65
+ items: 'item',
66
+ threads: 'thread',
67
+ messages: 'message',
68
+ links: 'link',
69
+ attachments: 'attachment',
70
+ watchers: 'watcher',
71
+ item_progress: 'item_progress'
72
+ }
73
+
62
74
  // ─── HANDLERS ─────────────────────────────────────────────────────────────────
63
75
 
64
76
  // ─── CHUNK_START: ADMIN_DATA_LIST ──────────────────────────────────────────────
@@ -89,17 +101,14 @@ export const list = createHandler(async (ctx, _body) => {
89
101
  let query = ctx.db.from(entity).select('*')
90
102
 
91
103
  // Apply type_slug filter if provided (for schema-driven entities)
92
- if (type_slug && entity === 'items') {
93
- // Look up the type ID from the slug
94
- const { data: typeRecord } = await adminDb
95
- .from('types')
96
- .select('id')
97
- .eq('slug', type_slug)
98
- .eq('is_active', true)
99
- .single()
100
-
101
- if (typeRecord) {
102
- query = query.eq('type_id', typeRecord.id)
104
+ if (type_slug) {
105
+ const kind = ENTITY_TO_KIND[entity]
106
+ if (kind) {
107
+ // Resolve the type ID from the slug via the shared resolver
108
+ const typeId = await resolveTypeId(kind, type_slug)
109
+ if (typeId) {
110
+ query = query.eq('type_id', typeId)
111
+ }
103
112
  }
104
113
  }
105
114
 
@@ -250,30 +259,41 @@ export const get = createHandler(async (ctx, _body) => {
250
259
  */
251
260
  export const create = createHandler(async (ctx, body) => {
252
261
  const entity = body?.entity || ctx.query?.entity
253
- const { entity: _e, design_schema: _ds, validation_schema: _vs, account_id: _ai, ...recordData } = body || {}
262
+ const { entity: _e, type_slug: _ts, design_schema: _ds, validation_schema: _vs, account_id: _ai, ...recordData } = body || {}
254
263
 
255
264
  if (!entity || !VALID_ENTITIES.includes(entity)) {
256
265
  const e: any = new Error('Valid entity parameter is required'); e.statusCode = 400; throw e
257
266
  }
258
267
 
268
+ // Resolve type_id from type_slug if provided, otherwise require type_id directly
269
+ let typeId = recordData.type_id
270
+ if (!typeId && body?.type_slug) {
271
+ const kind = ENTITY_TO_KIND[entity]
272
+ if (!kind) {
273
+ const e: any = new Error(`type_slug is not supported for entity: ${entity}`); e.statusCode = 400; throw e
274
+ }
275
+ typeId = await resolveTypeId(kind, body.type_slug)
276
+ }
277
+
259
278
  // type_id is required on all runtime record creation
260
- if (!recordData.type_id) {
261
- const e: any = new Error('type_id is required — every runtime record must reference a type'); e.statusCode = 400; throw e
279
+ if (!typeId) {
280
+ const e: any = new Error('type_id or type_slug is required — every runtime record must reference a type'); e.statusCode = 400; throw e
262
281
  }
282
+ recordData.type_id = typeId
263
283
 
264
284
  // Look up the type to stamp design_schema and validation_schema
265
285
  const { data: typeRecord, error: typeErr } = await adminDb
266
286
  .from('types')
267
287
  .select('id, design_schema, validation_schema, is_active')
268
- .eq('id', recordData.type_id)
288
+ .eq('id', typeId)
269
289
  .single()
270
290
 
271
291
  if (typeErr || !typeRecord) {
272
- throw new Error(`type_id not found: ${recordData.type_id}`)
292
+ throw new Error(`type_id not found: ${typeId}`)
273
293
  }
274
294
 
275
295
  if (!typeRecord.is_active) {
276
- throw new Error(`type_id references an inactive type: ${recordData.type_id}`)
296
+ throw new Error(`type_id references an inactive type: ${typeId}`)
277
297
  }
278
298
 
279
299
  // Ensure the type has at least permissions=ALL (defensive — migration 062 guarantees this)
@@ -282,8 +302,9 @@ export const create = createHandler(async (ctx, body) => {
282
302
  designSchema = { ...PERMISSIONS_ALL, ...designSchema }
283
303
  }
284
304
 
285
- // Resolve account_id and propagate scope via parent inheritance (Option A)
286
- // Priority: parent reference in body > type's own scope declaration
305
+ // Resolve account_id and propagate scope via parent inheritance.
306
+ // Runtime records are the source of truth: the parent record's stamped scope
307
+ // and account_id are inherited, not re-looked up from the type table.
287
308
  let recordAccountId = ctx.accountId
288
309
  const parentRef = recordData.thread_id || recordData.target_id || recordData.parent_id
289
310
  if (parentRef) {
@@ -301,16 +322,18 @@ export const create = createHandler(async (ctx, body) => {
301
322
  if (parentScope) {
302
323
  designSchema = { ...designSchema, scope: parentScope }
303
324
  }
304
- } else {
305
- const scope: string = designSchema.scope ?? 'account'
306
- if (scope === 'platform') {
307
- const { data: sysAccount } = await adminDb
308
- .from('accounts')
309
- .select('id')
310
- .eq('slug', 'spine-system')
311
- .single()
312
- if (sysAccount?.id) recordAccountId = sysAccount.id
313
- }
325
+ }
326
+
327
+ // Platform-scoped records belong to the system account so they are visible
328
+ // across all accounts. Child records already inherited the parent's scope above.
329
+ const resolvedScope: string = designSchema.scope ?? 'account'
330
+ if (resolvedScope === 'platform') {
331
+ const { data: sysAccount } = await adminDb
332
+ .from('accounts')
333
+ .select('id')
334
+ .eq('slug', 'spine-system')
335
+ .single()
336
+ if (sysAccount?.id) recordAccountId = sysAccount.id
314
337
  }
315
338
 
316
339
  // Add audit fields + stamped schema