| 1 | import { randomUUID } from 'node:crypto';
|
| 2 | import {
|
| 3 | LearningScopeSchema,
|
| 4 | ProvenanceSchema,
|
| 5 | SourceRecordEnvelopeSchema,
|
| 6 | canonicalHash,
|
| 7 | type LearningScope,
|
| 8 | type RetentionClass,
|
| 9 | type SourceRecordEnvelope,
|
| 10 | } from '../../protocol/src/index.js';
|
| 11 |
|
| 12 | export type SourceRecordMetadata = Partial<
|
| 13 | Pick<
|
| 14 | SourceRecordEnvelope,
|
| 15 | | 'eventId'
|
| 16 | | 'schemaName'
|
| 17 | | 'schemaVersion'
|
| 18 | | 'entityRefs'
|
| 19 | | 'provenance'
|
| 20 | | 'visibility'
|
| 21 | | 'retentionClass'
|
| 22 | | 'learningScope'
|
| 23 | | 'reportedAt'
|
| 24 | | 'ingestedAt'
|
| 25 | >
|
| 26 | >;
|
| 27 |
|
| 28 | export type SourceRecordWriteMetadata = SourceRecordMetadata & { journal?: boolean };
|
| 29 |
|
| 30 | export const SOURCE_JOURNAL_RECORD_KINDS = new Set([
|
| 31 | 'agent',
|
| 32 | 'agent_profile',
|
| 33 | 'completion_report',
|
| 34 | 'conflict',
|
| 35 | 'consent',
|
| 36 | 'contract',
|
| 37 | 'counterparty_brief',
|
| 38 | 'delegation',
|
| 39 | 'event',
|
| 40 | 'feedback',
|
| 41 | 'interaction',
|
| 42 | 'interaction_conclusion',
|
| 43 | 'interaction_feedback',
|
| 44 | 'learning_eligibility',
|
| 45 | 'federated_interaction',
|
| 46 | 'live_session_event',
|
| 47 | 'live_session_state',
|
| 48 | 'profile_delta',
|
| 49 | 'receipt',
|
| 50 | 'revocation',
|
| 51 | ]);
|
| 52 |
|
| 53 | export function shouldJournalSourceRecord(kind: string): boolean {
|
| 54 | return SOURCE_JOURNAL_RECORD_KINDS.has(kind);
|
| 55 | }
|
| 56 |
|
| 57 | const ACCOUNT_RECORD_KINDS = new Set([
|
| 58 | 'agent_profile',
|
| 59 | 'installation',
|
| 60 | 'presence',
|
| 61 | 'project',
|
| 62 | 'publication',
|
| 63 | 'setup_request',
|
| 64 | ]);
|
| 65 |
|
| 66 | const OPERATIONAL_RECORD_KINDS = new Set(['counterparty_brief', 'feedback_request', 'profile']);
|
| 67 |
|
| 68 | const SHARED_RECORD_KINDS = new Set([
|
| 69 | 'completion_report',
|
| 70 | 'contract',
|
| 71 | 'federated_interaction',
|
| 72 | 'interaction_conclusion',
|
| 73 | 'learning_eligibility',
|
| 74 | 'live_session_event',
|
| 75 | 'live_session_state',
|
| 76 | 'receipt',
|
| 77 | ]);
|
| 78 |
|
| 79 | const ENTITY_REFERENCE_FIELDS = [
|
| 80 | 'agentId',
|
| 81 | 'contractHash',
|
| 82 | 'counterpartyAgentId',
|
| 83 | 'decisionId',
|
| 84 | 'deploymentId',
|
| 85 | 'interactionId',
|
| 86 | 'projectId',
|
| 87 | 'reportId',
|
| 88 | 'reportingAgentId',
|
| 89 | 'requestId',
|
| 90 | 'reviewerAgentId',
|
| 91 | 'subjectAgentId',
|
| 92 | ] as const;
|
| 93 |
|
| 94 | const REPORTED_AT_FIELDS = [
|
| 95 | 'occurredAt',
|
| 96 | 'completedAt',
|
| 97 | 'decidedAt',
|
| 98 | 'appliedAt',
|
| 99 | 'generatedAt',
|
| 100 | 'createdAt',
|
| 101 | 'requestedAt',
|
| 102 | 'updatedAt',
|
| 103 | ] as const;
|
| 104 |
|
| 105 | function payloadObject(payload: unknown): Record<string, unknown> | undefined {
|
| 106 | return payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
| 107 | ? (payload as Record<string, unknown>)
|
| 108 | : undefined;
|
| 109 | }
|
| 110 |
|
| 111 | function defaultRetentionClass(kind: string): RetentionClass {
|
| 112 | if (ACCOUNT_RECORD_KINDS.has(kind)) return 'account';
|
| 113 | if (OPERATIONAL_RECORD_KINDS.has(kind)) return 'operational';
|
| 114 | return 'audit';
|
| 115 | }
|
| 116 |
|
| 117 | function inferredEntityRefs(payload: unknown): Record<string, string> {
|
| 118 | const object = payloadObject(payload);
|
| 119 | if (!object) return {};
|
| 120 | return Object.fromEntries(
|
| 121 | ENTITY_REFERENCE_FIELDS.flatMap((field) => {
|
| 122 | const value = object[field];
|
| 123 | return typeof value === 'string' && value ? [[field, value]] : [];
|
| 124 | }),
|
| 125 | );
|
| 126 | }
|
| 127 |
|
| 128 | function inferredProvenance(payload: unknown): SourceRecordEnvelope['provenance'] {
|
| 129 | const value = payloadObject(payload)?.provenance;
|
| 130 | const parsed = ProvenanceSchema.safeParse(value);
|
| 131 | return parsed.success ? parsed.data : 'observed';
|
| 132 | }
|
| 133 |
|
| 134 | function inferredLearningScope(payload: unknown): LearningScope {
|
| 135 | const object = payloadObject(payload);
|
| 136 | const candidate = object?.contributionMode ?? object?.learningScope;
|
| 137 | const parsed = LearningScopeSchema.safeParse(candidate);
|
| 138 | return parsed.success ? parsed.data : 'not_evaluated';
|
| 139 | }
|
| 140 |
|
| 141 | function inferredReportedAt(payload: unknown, fallback: string): string {
|
| 142 | const object = payloadObject(payload);
|
| 143 | if (!object) return fallback;
|
| 144 | for (const field of REPORTED_AT_FIELDS) {
|
| 145 | const value = object[field];
|
| 146 | if (typeof value === 'string' && Number.isFinite(Date.parse(value))) return value;
|
| 147 | }
|
| 148 | return fallback;
|
| 149 | }
|
| 150 |
|
| 151 | export function buildSourceRecordEnvelope(input: {
|
| 152 | operatorId: string;
|
| 153 | kind: string;
|
| 154 | recordId: string;
|
| 155 | payload: unknown;
|
| 156 | metadata?: SourceRecordMetadata;
|
| 157 | }): SourceRecordEnvelope {
|
| 158 | const ingestedAt = input.metadata?.ingestedAt ?? new Date().toISOString();
|
| 159 | return SourceRecordEnvelopeSchema.parse({
|
| 160 | eventId: input.metadata?.eventId ?? randomUUID(),
|
| 161 | operatorId: input.operatorId,
|
| 162 | kind: input.kind,
|
| 163 | recordId: input.recordId,
|
| 164 | schemaName: input.metadata?.schemaName ?? `openclasp.${input.kind}`,
|
| 165 | schemaVersion: input.metadata?.schemaVersion ?? '1',
|
| 166 | payload: input.payload,
|
| 167 | payloadDigest: canonicalHash(input.payload),
|
| 168 | entityRefs: {
|
| 169 | ...inferredEntityRefs(input.payload),
|
| 170 | ...input.metadata?.entityRefs,
|
| 171 | },
|
| 172 | provenance: input.metadata?.provenance ?? inferredProvenance(input.payload),
|
| 173 | visibility:
|
| 174 | input.metadata?.visibility ??
|
| 175 | (SHARED_RECORD_KINDS.has(input.kind) ? 'shared_participants' : 'local_only'),
|
| 176 | retentionClass: input.metadata?.retentionClass ?? defaultRetentionClass(input.kind),
|
| 177 | learningScope: input.metadata?.learningScope ?? inferredLearningScope(input.payload),
|
| 178 | reportedAt: input.metadata?.reportedAt ?? inferredReportedAt(input.payload, ingestedAt),
|
| 179 | ingestedAt,
|
| 180 | });
|
| 181 | }
|