@unson/brainbase-mcp 0.10.11 → 0.10.12

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.
@@ -0,0 +1,410 @@
1
+ export declare const KNOWLEDGE_PROMOTION_CONTRACT_VERSION: "knowledge_promotion.v1";
2
+ /** Kept byte-compatible with brainbase-unson so stored hashes and receipts still verify. */
3
+ export declare const NORMALIZED_PROMOTION_SCHEMA_VERSION: "personal_knowledge_normalized.v1";
4
+ export type PromotionApprovalKind = 'personal_share' | 'organization_candidate';
5
+ export type PromotionSourceKind = 'personal_knowledge_v1' | 'organization_candidate';
6
+ export type PromotionStatus = 'pending_owner_approval' | 'pending_org_review' | 'owner_rejected' | 'org_rejected' | 'org_accepted' | 'source_stale';
7
+ export type PromotionDecision = 'approve' | 'reject';
8
+ export type PromotionReviewAction = 'request' | 'owner_consent' | 'organization_review';
9
+ export declare class KnowledgePromotionError extends Error {
10
+ readonly code: string;
11
+ readonly status: number;
12
+ readonly details?: Record<string, unknown>;
13
+ constructor(code: string, status?: number, details?: Record<string, unknown>);
14
+ }
15
+ type JsonValue = null | boolean | number | string | JsonValue[] | {
16
+ [key: string]: JsonValue;
17
+ };
18
+ export interface NormalizedPromotionEntity {
19
+ id: string;
20
+ type: string;
21
+ payload: Record<string, JsonValue>;
22
+ }
23
+ export interface NormalizedPromotionEdge {
24
+ from_id: string;
25
+ to_id: string;
26
+ relation: string;
27
+ payload: Record<string, JsonValue>;
28
+ }
29
+ export interface NormalizedPromotionPayload {
30
+ schema_version: typeof NORMALIZED_PROMOTION_SCHEMA_VERSION;
31
+ kind: 'decision' | 'entity' | 'relation';
32
+ entity: NormalizedPromotionEntity;
33
+ edges: NormalizedPromotionEdge[];
34
+ context_entities: Array<{
35
+ id: string;
36
+ type: string;
37
+ }>;
38
+ decision_domain?: string;
39
+ sensitivity: string;
40
+ role_min: string;
41
+ }
42
+ export interface NormalizedPromotionResult {
43
+ normalized: NormalizedPromotionPayload;
44
+ normalized_payload_hash: string;
45
+ summary: string;
46
+ }
47
+ export declare function promotionSha256(value: unknown): string;
48
+ /** Validate and canonicalize the structured content a person approves for the organization. */
49
+ export declare function normalizePromotionPayload(input: unknown): NormalizedPromotionResult;
50
+ /** Owner consent receipt, identical to brainbase-unson's `pkoc_` receipt. */
51
+ export declare function ownerConsentReceipt(request: {
52
+ request_id: string;
53
+ owner_person_id: string | null;
54
+ owner_decided_by: string | null;
55
+ owner_decided_at: string | Date | null;
56
+ normalized_payload_hash: string;
57
+ }): string;
58
+ /** Organization review receipt of a personal share, identical to brainbase-unson's `pkor_` receipt. */
59
+ export declare function organizationReviewReceipt(request: {
60
+ request_id: string;
61
+ }, normalizedPayloadHash: string, reviewerPersonId: string, reviewedAt: string): string;
62
+ /** Organization approval receipt of an organization candidate. */
63
+ export declare function candidateApprovalReceipt(request: {
64
+ request_id: string;
65
+ source: PromotionSourceRef;
66
+ normalized_payload_hash: string;
67
+ }, reviewerPersonId: string, reviewedAt: string): string;
68
+ export interface PromotionSourceRef {
69
+ kind: PromotionSourceKind;
70
+ id: string;
71
+ version: string;
72
+ evidence_hash: string;
73
+ }
74
+ export interface PromotionDecisionRecord {
75
+ action: 'owner_consent' | 'organization_review';
76
+ decision: PromotionDecision;
77
+ actor_person_id: string;
78
+ decided_at: string;
79
+ receipt_id: string | null;
80
+ reason: string | null;
81
+ }
82
+ export interface KnowledgePromotionRequest {
83
+ request_id: string;
84
+ contract_version: typeof KNOWLEDGE_PROMOTION_CONTRACT_VERSION;
85
+ approval_kind: PromotionApprovalKind;
86
+ source: PromotionSourceRef;
87
+ source_event_ids: string[];
88
+ organization_id: string;
89
+ project_code: string;
90
+ owner_person_id: string | null;
91
+ requested_by_person_id: string;
92
+ status: PromotionStatus;
93
+ revision: number;
94
+ subject: {
95
+ type: string;
96
+ id: string;
97
+ };
98
+ sanitized_preview: string | null;
99
+ preview_hash: string | null;
100
+ normalized_payload: NormalizedPromotionPayload;
101
+ normalized_payload_hash: string;
102
+ normalization_contract_version: typeof NORMALIZED_PROMOTION_SCHEMA_VERSION;
103
+ owner_decided_by: string | null;
104
+ owner_decided_at: string | null;
105
+ owner_consent_receipt_id: string | null;
106
+ organization_review_receipt_id: string | null;
107
+ organization_event_id: string | null;
108
+ graph_entity_id: string | null;
109
+ decisions: PromotionDecisionRecord[];
110
+ created_at: string;
111
+ updated_at: string;
112
+ }
113
+ export interface KnowledgePromotionPrincipal {
114
+ personId: string;
115
+ /** The person who acts, when a service acts for `personId`. Defaults to `personId`. */
116
+ actorPersonId?: string;
117
+ organizationId: string;
118
+ projectCodes: string[];
119
+ role?: string;
120
+ principalType: 'person' | 'service';
121
+ }
122
+ /** Opaque signed authority. When present, the store records its single use. */
123
+ export interface KnowledgePromotionAuthority {
124
+ operationId: string;
125
+ idempotencyKey: string;
126
+ [key: string]: unknown;
127
+ }
128
+ export interface PromotionContext {
129
+ tx: unknown;
130
+ principal: KnowledgePromotionPrincipal;
131
+ }
132
+ export interface PersonalKnowledgeSourceRecord {
133
+ event_id: string;
134
+ owner_person_id: string;
135
+ organization_id: string;
136
+ body_hash: string;
137
+ version: string;
138
+ active: boolean;
139
+ }
140
+ export interface OrganizationCandidateSourceRecord {
141
+ candidate_id: string;
142
+ organization_id: string;
143
+ project_code: string;
144
+ version: string;
145
+ active: boolean;
146
+ evidence_hash: string;
147
+ source_event_ids?: string[];
148
+ }
149
+ export interface OrganizationCandidateOutcome {
150
+ candidate_id: string;
151
+ outcome: 'promoted' | 'rejected';
152
+ request_id: string;
153
+ actor_person_id: string;
154
+ receipt_id: string;
155
+ graph_entity_id: string | null;
156
+ organization_event_id: string | null;
157
+ decided_at: string;
158
+ }
159
+ export interface KnowledgePromotionLineage {
160
+ lineage_id: string;
161
+ contract_version: typeof KNOWLEDGE_PROMOTION_CONTRACT_VERSION;
162
+ approval_kind: PromotionApprovalKind;
163
+ promotion_request_id: string;
164
+ source: PromotionSourceRef;
165
+ source_event_ids: string[];
166
+ owner_person_id: string | null;
167
+ organization_id: string;
168
+ project_code: string;
169
+ organization_event_id: string;
170
+ graph_entity_id: string;
171
+ normalized_payload_hash: string;
172
+ receipts: {
173
+ owner_consent_receipt_id: string | null;
174
+ organization_review_receipt_id: string;
175
+ };
176
+ approved_by_person_id: string;
177
+ sanitization: {
178
+ raw_copied: false;
179
+ personal_body_copied: false;
180
+ sanitized_preview_copied: false;
181
+ };
182
+ created_at: string;
183
+ }
184
+ export interface KnowledgePromotionStore {
185
+ transaction<T>(work: (tx: unknown) => Promise<T>, options?: {
186
+ principal: KnowledgePromotionPrincipal;
187
+ }): Promise<T>;
188
+ /** Insert, or return the existing request with the same `request_id`. */
189
+ createRequest(request: KnowledgePromotionRequest, ctx: PromotionContext): Promise<KnowledgePromotionRequest>;
190
+ findRequest(requestId: string, ctx: PromotionContext): Promise<KnowledgePromotionRequest | null>;
191
+ /** Apply `patch` and increment `revision` only when the stored revision equals `expectedRevision`; otherwise `null`. */
192
+ updateRequest(requestId: string, patch: Partial<KnowledgePromotionRequest>, options: {
193
+ expectedRevision: number;
194
+ }, ctx: PromotionContext): Promise<KnowledgePromotionRequest | null>;
195
+ createLineage(lineage: KnowledgePromotionLineage, ctx: PromotionContext): Promise<void>;
196
+ claimAuthorityUse?(use: {
197
+ operation_id: string;
198
+ idempotency_key: string;
199
+ request_id: string;
200
+ action: PromotionReviewAction;
201
+ actor_person_id: string;
202
+ organization_id: string;
203
+ project_code: string;
204
+ }, ctx: PromotionContext): Promise<void>;
205
+ }
206
+ export interface KnowledgePromotionSources {
207
+ readPersonalKnowledgeEvent?(eventId: string, ctx: PromotionContext): Promise<PersonalKnowledgeSourceRecord | null>;
208
+ readOrganizationCandidate?(candidateId: string, ctx: PromotionContext): Promise<OrganizationCandidateSourceRecord | null>;
209
+ recordOrganizationCandidateOutcome?(outcome: OrganizationCandidateOutcome, ctx: PromotionContext): Promise<void>;
210
+ }
211
+ export type PromotionReviewDecisionResult = {
212
+ allowed: true;
213
+ decider_person_id?: string;
214
+ } | {
215
+ allowed: false;
216
+ code: string;
217
+ status?: number;
218
+ details?: Record<string, unknown>;
219
+ };
220
+ export interface PromotionReviewInput {
221
+ action: PromotionReviewAction;
222
+ approvalKind: PromotionApprovalKind;
223
+ request: KnowledgePromotionRequest;
224
+ normalized: NormalizedPromotionPayload;
225
+ principal: KnowledgePromotionPrincipal;
226
+ authority: KnowledgePromotionAuthority | null;
227
+ }
228
+ /** Organization-owned review rules (brainbase-organization). */
229
+ export interface PromotionReviewPolicy {
230
+ authorize(input: PromotionReviewInput, ctx: PromotionContext): Promise<PromotionReviewDecisionResult>;
231
+ }
232
+ export interface KnowledgeEventRecordResult {
233
+ event_id: string;
234
+ candidate_id?: string | null;
235
+ semantic_state?: string;
236
+ quarantine_reason?: string | null;
237
+ }
238
+ export interface KnowledgePromotionEventSink {
239
+ recordOrganizationEvent(event: OrganizationKnowledgeEvent, ctx: PromotionContext): Promise<KnowledgeEventRecordResult>;
240
+ reconcileGraphProjection?(input: {
241
+ candidate_id: string;
242
+ graph_entity_id: string;
243
+ event_id: string;
244
+ actor_person_id: string;
245
+ decision_owner_person_id: string;
246
+ }, ctx: PromotionContext): Promise<void>;
247
+ }
248
+ export interface KnowledgePromotionGraphWriter {
249
+ commitNormalizedPromotion(mutation: NormalizedGraphMutation, ctx: PromotionContext): Promise<{
250
+ id: string;
251
+ } | null>;
252
+ }
253
+ export interface KnowledgePromotionPorts {
254
+ store: KnowledgePromotionStore;
255
+ sources: KnowledgePromotionSources;
256
+ reviewPolicy: PromotionReviewPolicy | null;
257
+ knowledgeEvents: KnowledgePromotionEventSink;
258
+ graph: KnowledgePromotionGraphWriter;
259
+ now?: () => Date;
260
+ }
261
+ export interface PromotionEvidence {
262
+ contract_version: typeof NORMALIZED_PROMOTION_SCHEMA_VERSION;
263
+ approval_kind: PromotionApprovalKind;
264
+ promotion_request_id: string;
265
+ source_kind: PromotionSourceKind;
266
+ source_version: string;
267
+ source_evidence_hash: string;
268
+ normalized_payload_hash: string;
269
+ owner_consent_receipt_id: string | null;
270
+ organization_review_receipt_id: string;
271
+ source_pointer: {
272
+ uri: string;
273
+ digest: string;
274
+ };
275
+ }
276
+ export interface OrganizationKnowledgeEvent {
277
+ schema_version: 'knowledge_event.v1';
278
+ event_id: string;
279
+ occurred_at: string;
280
+ captured_at: string;
281
+ source: {
282
+ type: string;
283
+ venue: string;
284
+ contract_version: string;
285
+ };
286
+ subject: {
287
+ type: string;
288
+ id: string;
289
+ };
290
+ decision?: {
291
+ statement: JsonValue;
292
+ };
293
+ decision_authority: {
294
+ authorized: true;
295
+ decider_id: string;
296
+ domain: string;
297
+ };
298
+ applicability_scope: {
299
+ scope: 'organization';
300
+ organization_id: string;
301
+ project_code: string;
302
+ };
303
+ permission_snapshot: Record<string, unknown>;
304
+ source_pointer: {
305
+ uri: string;
306
+ digest: string;
307
+ };
308
+ body_hash: string;
309
+ parent_episode_id: string;
310
+ payload: {
311
+ summary: string;
312
+ normalized_kind: string;
313
+ promotion_evidence: PromotionEvidence;
314
+ };
315
+ promotion_evidence: PromotionEvidence;
316
+ organization_id: string;
317
+ project_code: string;
318
+ sensitivity: string;
319
+ role_min: string;
320
+ venue: string;
321
+ }
322
+ export interface NormalizedGraphMutation {
323
+ project_code: string;
324
+ entity: {
325
+ id: string;
326
+ type: string;
327
+ payload: Record<string, unknown>;
328
+ };
329
+ edges: Array<NormalizedPromotionEdge & {
330
+ payload: Record<string, unknown>;
331
+ }>;
332
+ context_entities: Array<{
333
+ id: string;
334
+ type: string;
335
+ }>;
336
+ role_min: string;
337
+ sensitivity: string;
338
+ evidence: PromotionEvidence & {
339
+ organization_event_id: string;
340
+ candidate_id: string | null;
341
+ };
342
+ }
343
+ export declare function buildPromotionEvidence(request: KnowledgePromotionRequest, organizationReviewReceiptId: string): PromotionEvidence;
344
+ /** The organization knowledge event that records the approval. The approval kind is never disguised. */
345
+ export declare function buildOrganizationKnowledgeEvent(request: KnowledgePromotionRequest, normalized: NormalizedPromotionPayload, summary: string, evidence: PromotionEvidence, approverPersonId: string, occurredAt: string): OrganizationKnowledgeEvent;
346
+ /** Graph mutation carrying only normalized content and provenance pointers. */
347
+ export declare function buildNormalizedGraphMutation(request: KnowledgePromotionRequest, normalized: NormalizedPromotionPayload, evidence: PromotionEvidence, eventResult: KnowledgeEventRecordResult, organizationEvent: OrganizationKnowledgeEvent): NormalizedGraphMutation;
348
+ export declare function promotionRequestId(input: {
349
+ approval_kind: PromotionApprovalKind;
350
+ source: PromotionSourceRef;
351
+ project_code: string;
352
+ normalized_payload_hash: string;
353
+ }): string;
354
+ export declare class KnowledgePromotionService {
355
+ readonly ports: KnowledgePromotionPorts;
356
+ private readonly now;
357
+ constructor(ports: KnowledgePromotionPorts);
358
+ /** D4: request sharing a Personal Knowledge v1 event owned by the authenticated person. */
359
+ requestPersonalShare(input: {
360
+ personal_event_id: string;
361
+ project_code: string;
362
+ summary: string;
363
+ subject?: {
364
+ type: string;
365
+ id: string;
366
+ };
367
+ normalized_payload: unknown;
368
+ }, context: {
369
+ principal: KnowledgePromotionPrincipal;
370
+ authority?: KnowledgePromotionAuthority | null;
371
+ }): Promise<KnowledgePromotionRequest>;
372
+ /** D12: request promoting an organization candidate waiting for the Graph promotion review. */
373
+ requestOrganizationCandidate(input: {
374
+ candidate_id: string;
375
+ project_code: string;
376
+ normalized_payload: unknown;
377
+ }, context: {
378
+ principal: KnowledgePromotionPrincipal;
379
+ authority?: KnowledgePromotionAuthority | null;
380
+ }): Promise<KnowledgePromotionRequest>;
381
+ /** D4: the owner approves or rejects sharing the exact normalized content. */
382
+ decideOwnerConsent(requestId: string, input: {
383
+ decision: PromotionDecision;
384
+ normalized_payload_hash?: string;
385
+ expected_revision: number;
386
+ }, context: {
387
+ principal: KnowledgePromotionPrincipal;
388
+ authority?: KnowledgePromotionAuthority | null;
389
+ }): Promise<KnowledgePromotionRequest>;
390
+ /**
391
+ * D4: organization review of an owner-approved personal share.
392
+ * D12: organization approval of an organization candidate.
393
+ * Only an approval commits the event, the Graph mutation, the lineage, and the request in one transaction.
394
+ */
395
+ reviewOrganization(requestId: string, input: {
396
+ decision: PromotionDecision;
397
+ reason?: string;
398
+ expected_revision: number;
399
+ }, context: {
400
+ principal: KnowledgePromotionPrincipal;
401
+ authority?: KnowledgePromotionAuthority | null;
402
+ }): Promise<KnowledgePromotionRequest>;
403
+ private requirePolicy;
404
+ private draftRequest;
405
+ private authorize;
406
+ private claimAuthority;
407
+ private sourceIsCurrent;
408
+ private recordCandidateOutcome;
409
+ }
410
+ export {};
@@ -0,0 +1,843 @@
1
+ /**
2
+ * Common knowledge promotion (brainbase-project ADR-006 D4・D12).
3
+ *
4
+ * Two approval kinds pass through the same service and reach the organization
5
+ * Graph only after an organization approval:
6
+ *
7
+ * - `personal_share`: a Personal Knowledge v1 event → owner consent →
8
+ * organization review → Graph.
9
+ * - `organization_candidate`: an organization candidate (meeting observation,
10
+ * task candidate, ...) → organization approval → Graph. It never takes the
11
+ * shape of an owner consent.
12
+ *
13
+ * This module owns normalization, receipts, evidence, lineage, the Graph
14
+ * mutation, and the state machine. Storage, the source readers, the knowledge
15
+ * event kernel, the Graph writer, and the organization review policy (roles,
16
+ * decision authority) are injected. A missing port stops the operation.
17
+ */
18
+ import { createHash } from 'node:crypto';
19
+ export const KNOWLEDGE_PROMOTION_CONTRACT_VERSION = 'knowledge_promotion.v1';
20
+ /** Kept byte-compatible with brainbase-unson so stored hashes and receipts still verify. */
21
+ export const NORMALIZED_PROMOTION_SCHEMA_VERSION = 'personal_knowledge_normalized.v1';
22
+ export class KnowledgePromotionError extends Error {
23
+ code;
24
+ status;
25
+ details;
26
+ constructor(code, status = 400, details) {
27
+ super(code);
28
+ this.name = 'KnowledgePromotionError';
29
+ this.code = code;
30
+ this.status = status;
31
+ if (details !== undefined)
32
+ this.details = details;
33
+ }
34
+ }
35
+ // ---------------------------------------------------------------------------
36
+ // Normalization (same rules as brainbase-unson personal-knowledge-normalization.js)
37
+ // ---------------------------------------------------------------------------
38
+ const ALLOWED_KINDS = new Set(['decision', 'entity', 'relation']);
39
+ const ALLOWED_ROLES = new Set(['member', 'gm', 'ceo']);
40
+ const ALLOWED_SENSITIVITIES = new Set(['internal', 'restricted', 'finance', 'hr', 'contract']);
41
+ const SAFE_IDENTIFIER = /^[a-z][a-z0-9_.:-]{0,199}$/i;
42
+ const SECRET_OR_PRIVATE = /(secret\s*=|password\s*=|api[_-]?key\s*=|bearer\s+[a-z0-9._-]+|\/Users\/|\/home\/|[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,})/i;
43
+ const FORBIDDEN_KEY = /(^|_)(body|raw|transcript|conversation|message|prompt|private|personal|excerpt|preview|content|note)(_|$)/i;
44
+ const SERVER_CONTROLLED_KEY = /(^|_)(source_pointer|source_evidence|owner_consent|organization_review|promotion_evidence|receipt)(_|$)/i;
45
+ const MAX_PAYLOAD_BYTES = 16 * 1024;
46
+ const MAX_DEPTH = 6;
47
+ const MAX_ARRAY_ITEMS = 50;
48
+ const MAX_OBJECT_KEYS = 80;
49
+ function canonicalJson(value) {
50
+ if (Array.isArray(value))
51
+ return `[${value.map(canonicalJson).join(',')}]`;
52
+ if (value && typeof value === 'object') {
53
+ const record = value;
54
+ return `{${Object.keys(record).sort().map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(',')}}`;
55
+ }
56
+ return JSON.stringify(value);
57
+ }
58
+ export function promotionSha256(value) {
59
+ return createHash('sha256').update(typeof value === 'string' ? value : canonicalJson(value)).digest('hex');
60
+ }
61
+ function requirePlainObject(value, code) {
62
+ if (!value || typeof value !== 'object' || Array.isArray(value))
63
+ throw new KnowledgePromotionError(code);
64
+ const prototype = Object.getPrototypeOf(value);
65
+ if (prototype !== Object.prototype && prototype !== null)
66
+ throw new KnowledgePromotionError(code);
67
+ return value;
68
+ }
69
+ function safeIdentifier(value, code) {
70
+ const result = String(value ?? '').trim();
71
+ if (!SAFE_IDENTIFIER.test(result) || SECRET_OR_PRIVATE.test(result))
72
+ throw new KnowledgePromotionError(code);
73
+ return result;
74
+ }
75
+ function safeText(value, code, maxLength = 1000) {
76
+ const result = String(value ?? '').trim();
77
+ if (!result || result.length > maxLength || SECRET_OR_PRIVATE.test(result))
78
+ throw new KnowledgePromotionError(code);
79
+ return result;
80
+ }
81
+ function sanitizeStructuredValue(value, depth = 0) {
82
+ if (depth > MAX_DEPTH)
83
+ throw new KnowledgePromotionError('personal_knowledge_normalized_payload_too_deep');
84
+ if (value === null || typeof value === 'boolean')
85
+ return value;
86
+ if (typeof value === 'number') {
87
+ if (!Number.isFinite(value))
88
+ throw new KnowledgePromotionError('personal_knowledge_normalized_payload_invalid_number');
89
+ return value;
90
+ }
91
+ if (typeof value === 'string')
92
+ return safeText(value, 'personal_knowledge_normalized_payload_contains_private_content');
93
+ if (Array.isArray(value)) {
94
+ if (value.length > MAX_ARRAY_ITEMS)
95
+ throw new KnowledgePromotionError('personal_knowledge_normalized_payload_too_large');
96
+ return value.map((item) => sanitizeStructuredValue(item, depth + 1));
97
+ }
98
+ const object = requirePlainObject(value, 'personal_knowledge_normalized_payload_invalid');
99
+ const keys = Object.keys(object);
100
+ if (keys.length > MAX_OBJECT_KEYS)
101
+ throw new KnowledgePromotionError('personal_knowledge_normalized_payload_too_large');
102
+ const result = {};
103
+ for (const key of keys) {
104
+ if (!/^[a-z][a-z0-9_]{0,79}$/i.test(key)
105
+ || FORBIDDEN_KEY.test(key)
106
+ || SERVER_CONTROLLED_KEY.test(key)
107
+ || ['__proto__', 'prototype', 'constructor'].includes(key)) {
108
+ throw new KnowledgePromotionError('personal_knowledge_normalized_payload_forbidden_field');
109
+ }
110
+ result[key] = sanitizeStructuredValue(object[key], depth + 1);
111
+ }
112
+ return result;
113
+ }
114
+ function normalizeEntity(value) {
115
+ const entity = requirePlainObject(value, 'personal_knowledge_normalized_entity_required');
116
+ if (Object.keys(entity).some((key) => !['id', 'type', 'payload'].includes(key))) {
117
+ throw new KnowledgePromotionError('personal_knowledge_normalized_entity_forbidden_field');
118
+ }
119
+ const payload = sanitizeStructuredValue(requirePlainObject(entity.payload, 'personal_knowledge_normalized_entity_payload_required'));
120
+ return {
121
+ id: safeIdentifier(entity.id, 'personal_knowledge_normalized_entity_id_invalid'),
122
+ type: safeIdentifier(entity.type, 'personal_knowledge_normalized_entity_type_invalid'),
123
+ payload,
124
+ };
125
+ }
126
+ function normalizeContextEntities(value = []) {
127
+ if (!Array.isArray(value) || value.length > 40) {
128
+ throw new KnowledgePromotionError('personal_knowledge_normalized_context_entities_invalid');
129
+ }
130
+ return value.map((item) => {
131
+ const context = requirePlainObject(item, 'personal_knowledge_normalized_context_entity_invalid');
132
+ if (Object.keys(context).some((key) => !['id', 'type'].includes(key))) {
133
+ throw new KnowledgePromotionError('personal_knowledge_normalized_context_entity_forbidden_field');
134
+ }
135
+ return {
136
+ id: safeIdentifier(context.id, 'personal_knowledge_normalized_context_entity_id_invalid'),
137
+ type: safeIdentifier(context.type, 'personal_knowledge_normalized_context_entity_type_invalid'),
138
+ };
139
+ });
140
+ }
141
+ function normalizeEdges(value, entityId, contextEntities) {
142
+ if (!Array.isArray(value) || value.length > 20) {
143
+ throw new KnowledgePromotionError('personal_knowledge_normalized_edges_invalid');
144
+ }
145
+ const endpointIds = new Set([entityId, ...contextEntities.map((item) => item.id)]);
146
+ return value.map((item) => {
147
+ const edge = requirePlainObject(item, 'personal_knowledge_normalized_edge_invalid');
148
+ if (Object.keys(edge).some((key) => !['from_id', 'to_id', 'relation', 'payload'].includes(key))) {
149
+ throw new KnowledgePromotionError('personal_knowledge_normalized_edge_forbidden_field');
150
+ }
151
+ const normalized = {
152
+ from_id: safeIdentifier(edge.from_id, 'personal_knowledge_normalized_edge_endpoint_invalid'),
153
+ to_id: safeIdentifier(edge.to_id, 'personal_knowledge_normalized_edge_endpoint_invalid'),
154
+ relation: safeIdentifier(edge.relation, 'personal_knowledge_normalized_edge_relation_invalid'),
155
+ payload: edge.payload === undefined
156
+ ? {}
157
+ : sanitizeStructuredValue(requirePlainObject(edge.payload, 'personal_knowledge_normalized_edge_payload_invalid')),
158
+ };
159
+ if (!endpointIds.has(normalized.from_id) || !endpointIds.has(normalized.to_id)) {
160
+ throw new KnowledgePromotionError('personal_knowledge_normalized_edge_context_missing');
161
+ }
162
+ if (normalized.from_id !== entityId && normalized.to_id !== entityId) {
163
+ throw new KnowledgePromotionError('personal_knowledge_normalized_edge_must_include_primary_entity');
164
+ }
165
+ return normalized;
166
+ });
167
+ }
168
+ function normalizedSummary(normalized) {
169
+ const payload = normalized.entity.payload || {};
170
+ const candidate = payload.statement || payload.label || payload.name || payload.title || normalized.entity.id;
171
+ return safeText(candidate, 'personal_knowledge_normalized_summary_required', 1000);
172
+ }
173
+ /** Validate and canonicalize the structured content a person approves for the organization. */
174
+ export function normalizePromotionPayload(input) {
175
+ const value = requirePlainObject(input, 'personal_knowledge_normalized_payload_required');
176
+ const allowed = new Set([
177
+ 'schema_version', 'kind', 'entity', 'edges', 'context_entities',
178
+ 'decision_domain', 'sensitivity', 'role_min',
179
+ ]);
180
+ if (Object.keys(value).some((key) => !allowed.has(key))) {
181
+ throw new KnowledgePromotionError('personal_knowledge_normalized_payload_forbidden_field');
182
+ }
183
+ if (value.schema_version !== NORMALIZED_PROMOTION_SCHEMA_VERSION) {
184
+ throw new KnowledgePromotionError('personal_knowledge_normalized_payload_schema_invalid');
185
+ }
186
+ const kind = String(value.kind ?? '').trim();
187
+ if (!ALLOWED_KINDS.has(kind))
188
+ throw new KnowledgePromotionError('personal_knowledge_normalized_payload_kind_invalid');
189
+ const entity = normalizeEntity(value.entity);
190
+ const contextEntities = normalizeContextEntities(value.context_entities ?? []);
191
+ const edges = normalizeEdges(value.edges ?? [], entity.id, contextEntities);
192
+ if (kind === 'decision' && entity.type !== 'decision') {
193
+ throw new KnowledgePromotionError('personal_knowledge_normalized_decision_type_required');
194
+ }
195
+ if (kind !== 'decision' && entity.type === 'decision') {
196
+ throw new KnowledgePromotionError('personal_knowledge_normalized_kind_mismatch');
197
+ }
198
+ if (kind === 'relation' && edges.length === 0) {
199
+ throw new KnowledgePromotionError('personal_knowledge_normalized_relation_edge_required');
200
+ }
201
+ const decisionDomain = kind === 'decision'
202
+ ? safeIdentifier(value.decision_domain, 'personal_knowledge_normalized_decision_domain_required')
203
+ : null;
204
+ if (kind === 'decision') {
205
+ safeText(entity.payload.statement, 'personal_knowledge_normalized_decision_statement_required', 1000);
206
+ }
207
+ else if (value.decision_domain !== undefined) {
208
+ throw new KnowledgePromotionError('personal_knowledge_normalized_decision_domain_not_allowed');
209
+ }
210
+ const sensitivity = String(value.sensitivity ?? 'internal').toLowerCase();
211
+ if (!ALLOWED_SENSITIVITIES.has(sensitivity)) {
212
+ throw new KnowledgePromotionError('personal_knowledge_normalized_sensitivity_invalid');
213
+ }
214
+ const roleMin = String(value.role_min ?? 'member').toLowerCase();
215
+ if (!ALLOWED_ROLES.has(roleMin))
216
+ throw new KnowledgePromotionError('personal_knowledge_normalized_role_invalid');
217
+ const normalized = {
218
+ schema_version: NORMALIZED_PROMOTION_SCHEMA_VERSION,
219
+ kind: kind,
220
+ entity,
221
+ edges,
222
+ context_entities: contextEntities,
223
+ ...(decisionDomain ? { decision_domain: decisionDomain } : {}),
224
+ sensitivity,
225
+ role_min: roleMin,
226
+ };
227
+ if (Buffer.byteLength(canonicalJson(normalized), 'utf8') > MAX_PAYLOAD_BYTES) {
228
+ throw new KnowledgePromotionError('personal_knowledge_normalized_payload_too_large');
229
+ }
230
+ return {
231
+ normalized,
232
+ normalized_payload_hash: `sha256:${promotionSha256(normalized)}`,
233
+ summary: normalizedSummary(normalized),
234
+ };
235
+ }
236
+ // ---------------------------------------------------------------------------
237
+ // Receipts
238
+ // ---------------------------------------------------------------------------
239
+ function isoTimestamp(value) {
240
+ return value instanceof Date ? value.toISOString() : String(value);
241
+ }
242
+ /** Owner consent receipt, identical to brainbase-unson's `pkoc_` receipt. */
243
+ export function ownerConsentReceipt(request) {
244
+ if (!request.owner_decided_by || !request.owner_decided_at || !request.normalized_payload_hash) {
245
+ throw new KnowledgePromotionError('knowledge_promotion_owner_consent_receipt_unavailable', 409);
246
+ }
247
+ return `pkoc_${promotionSha256({
248
+ request_id: request.request_id,
249
+ owner_person_id: request.owner_person_id,
250
+ owner_decided_by: request.owner_decided_by,
251
+ owner_decided_at: isoTimestamp(request.owner_decided_at),
252
+ normalized_payload_hash: request.normalized_payload_hash,
253
+ }).slice(0, 24)}`;
254
+ }
255
+ /** Organization review receipt of a personal share, identical to brainbase-unson's `pkor_` receipt. */
256
+ export function organizationReviewReceipt(request, normalizedPayloadHash, reviewerPersonId, reviewedAt) {
257
+ return `pkor_${promotionSha256({
258
+ request_id: request.request_id,
259
+ normalized_payload_hash: normalizedPayloadHash,
260
+ reviewer_person_id: reviewerPersonId,
261
+ reviewed_at: reviewedAt,
262
+ }).slice(0, 24)}`;
263
+ }
264
+ /** Organization approval receipt of an organization candidate. */
265
+ export function candidateApprovalReceipt(request, reviewerPersonId, reviewedAt) {
266
+ return `pkca_${promotionSha256({
267
+ approval_kind: 'organization_candidate',
268
+ request_id: request.request_id,
269
+ source_kind: request.source.kind,
270
+ source_id: request.source.id,
271
+ source_version: request.source.version,
272
+ normalized_payload_hash: request.normalized_payload_hash,
273
+ reviewer_person_id: reviewerPersonId,
274
+ reviewed_at: reviewedAt,
275
+ }).slice(0, 24)}`;
276
+ }
277
+ const DECISIONS = new Set(['approve', 'reject']);
278
+ const TERMINAL = new Set(['owner_rejected', 'org_rejected', 'org_accepted', 'source_stale']);
279
+ function sanitizePreview(value) {
280
+ const text = String(value ?? '').trim().slice(0, 2000);
281
+ if (!text || SECRET_OR_PRIVATE.test(text))
282
+ throw new KnowledgePromotionError('knowledge_promotion_requires_safe_preview');
283
+ return text;
284
+ }
285
+ function sanitizeReason(value) {
286
+ if (value === undefined || value === null || value === '')
287
+ return null;
288
+ const text = String(value).trim().slice(0, 500);
289
+ if (!text || SECRET_OR_PRIVATE.test(text))
290
+ throw new KnowledgePromotionError('knowledge_promotion_requires_safe_reason');
291
+ return text;
292
+ }
293
+ function sanitizeSubject(value, fallback) {
294
+ const subject = value && typeof value === 'object' ? value : fallback;
295
+ const type = String(subject.type ?? '').trim();
296
+ const id = String(subject.id ?? '').trim();
297
+ if (!/^[a-z][a-z0-9_-]{0,79}$/i.test(type) || !id || id.length > 200 || SECRET_OR_PRIVATE.test(id)) {
298
+ throw new KnowledgePromotionError('knowledge_promotion_requires_safe_subject');
299
+ }
300
+ return { type, id };
301
+ }
302
+ function requireText(value, code) {
303
+ if (typeof value !== 'string' || !value.trim() || value.length > 240)
304
+ throw new KnowledgePromotionError(code);
305
+ return value;
306
+ }
307
+ function requireHuman(principal) {
308
+ if (!principal?.personId || !principal.organizationId) {
309
+ throw new KnowledgePromotionError('knowledge_promotion_identity_required', 403);
310
+ }
311
+ // Scheduled routines and service principals never request or approve.
312
+ if (principal.principalType !== 'person') {
313
+ throw new KnowledgePromotionError('knowledge_promotion_human_principal_required', 403);
314
+ }
315
+ return principal;
316
+ }
317
+ function actorOf(principal) {
318
+ return principal.actorPersonId || principal.personId;
319
+ }
320
+ function requireProjectAccess(principal, projectCode) {
321
+ if (!Array.isArray(principal.projectCodes) || !principal.projectCodes.includes(projectCode)) {
322
+ throw new KnowledgePromotionError('knowledge_promotion_project_access_denied', 403);
323
+ }
324
+ }
325
+ function requireExpectedRevision(value, request) {
326
+ if (!Number.isInteger(value) || value < 0) {
327
+ throw new KnowledgePromotionError('knowledge_promotion_expected_revision_required', 400);
328
+ }
329
+ if (value !== request.revision) {
330
+ throw new KnowledgePromotionError('knowledge_promotion_stale_revision', 409, {
331
+ expected_revision: value,
332
+ current_revision: request.revision,
333
+ });
334
+ }
335
+ return value;
336
+ }
337
+ function sourcePointer(requestId, normalizedPayloadHash) {
338
+ return { uri: `brainbase://knowledge-promotion-receipts/${requestId}`, digest: normalizedPayloadHash };
339
+ }
340
+ export function buildPromotionEvidence(request, organizationReviewReceiptId) {
341
+ return {
342
+ contract_version: NORMALIZED_PROMOTION_SCHEMA_VERSION,
343
+ approval_kind: request.approval_kind,
344
+ promotion_request_id: request.request_id,
345
+ source_kind: request.source.kind,
346
+ source_version: request.source.version,
347
+ source_evidence_hash: request.source.evidence_hash,
348
+ normalized_payload_hash: request.normalized_payload_hash,
349
+ owner_consent_receipt_id: request.owner_consent_receipt_id,
350
+ organization_review_receipt_id: organizationReviewReceiptId,
351
+ source_pointer: sourcePointer(request.request_id, request.normalized_payload_hash),
352
+ };
353
+ }
354
+ /** The organization knowledge event that records the approval. The approval kind is never disguised. */
355
+ export function buildOrganizationKnowledgeEvent(request, normalized, summary, evidence, approverPersonId, occurredAt) {
356
+ const isDecision = normalized.kind === 'decision';
357
+ const personal = request.approval_kind === 'personal_share';
358
+ const decisionAuthority = isDecision
359
+ // A personal decision is the owner's; a candidate decision is owned by the approver
360
+ // whose decision authority the organization policy verified.
361
+ ? { authorized: true, decider_id: personal ? request.owner_person_id : approverPersonId, domain: normalized.decision_domain }
362
+ : { authorized: true, decider_id: approverPersonId, domain: 'organization_knowledge_review' };
363
+ return {
364
+ schema_version: 'knowledge_event.v1',
365
+ event_id: `kev_prom_${promotionSha256(`${request.request_id}:${evidence.normalized_payload_hash}`).slice(0, 24)}`,
366
+ occurred_at: occurredAt,
367
+ captured_at: occurredAt,
368
+ source: {
369
+ type: personal ? 'personal_knowledge_promotion' : 'organization_candidate_promotion',
370
+ venue: personal ? 'organization_review' : 'organization_candidate_approval',
371
+ contract_version: NORMALIZED_PROMOTION_SCHEMA_VERSION,
372
+ },
373
+ subject: { type: normalized.entity.type, id: normalized.entity.id },
374
+ ...(isDecision ? { decision: { statement: normalized.entity.payload.statement } } : {}),
375
+ decision_authority: decisionAuthority,
376
+ applicability_scope: { scope: 'organization', organization_id: request.organization_id, project_code: request.project_code },
377
+ permission_snapshot: {
378
+ visibility: 'org',
379
+ contains_pii: false,
380
+ sensitivity: normalized.sensitivity,
381
+ role_min: normalized.role_min,
382
+ approval_kind: request.approval_kind,
383
+ ...(personal ? { owner_consented: true, owner_consent_receipt_id: evidence.owner_consent_receipt_id } : {}),
384
+ organization_reviewed: true,
385
+ organization_review_receipt_id: evidence.organization_review_receipt_id,
386
+ },
387
+ source_pointer: evidence.source_pointer,
388
+ body_hash: evidence.normalized_payload_hash,
389
+ parent_episode_id: `episode_${personal ? 'personal' : 'candidate'}_promotion_${promotionSha256(request.request_id).slice(0, 24)}`,
390
+ payload: { summary, normalized_kind: normalized.kind, promotion_evidence: evidence },
391
+ promotion_evidence: evidence,
392
+ organization_id: request.organization_id,
393
+ project_code: request.project_code,
394
+ sensitivity: normalized.sensitivity,
395
+ role_min: normalized.role_min,
396
+ venue: personal ? 'personal_knowledge_promotion' : 'organization_candidate_promotion',
397
+ };
398
+ }
399
+ /** Graph mutation carrying only normalized content and provenance pointers. */
400
+ export function buildNormalizedGraphMutation(request, normalized, evidence, eventResult, organizationEvent) {
401
+ const derived = { ...evidence, organization_event_id: eventResult.event_id, candidate_id: eventResult.candidate_id ?? null };
402
+ return {
403
+ project_code: request.project_code,
404
+ entity: {
405
+ id: normalized.entity.id,
406
+ type: normalized.entity.type,
407
+ payload: {
408
+ ...normalized.entity.payload,
409
+ ...(normalized.kind === 'decision' ? {
410
+ statement: normalized.entity.payload.statement,
411
+ applicability_scope: organizationEvent.applicability_scope,
412
+ decision_authority: organizationEvent.decision_authority,
413
+ occurred_at: organizationEvent.occurred_at,
414
+ source_pointer: organizationEvent.source_pointer,
415
+ } : {
416
+ applicability_scope: organizationEvent.applicability_scope,
417
+ source_pointer: organizationEvent.source_pointer,
418
+ }),
419
+ derived_from_event_id: eventResult.event_id,
420
+ derived_from_candidate_id: eventResult.candidate_id ?? null,
421
+ semantic_state: 'active',
422
+ searchable: true,
423
+ promotion_evidence: derived,
424
+ },
425
+ },
426
+ edges: normalized.edges.map((edge) => ({
427
+ ...edge,
428
+ payload: {
429
+ ...(edge.payload || {}),
430
+ promotion_evidence: {
431
+ approval_kind: request.approval_kind,
432
+ organization_event_id: eventResult.event_id,
433
+ normalized_payload_hash: evidence.normalized_payload_hash,
434
+ owner_consent_receipt_id: evidence.owner_consent_receipt_id,
435
+ organization_review_receipt_id: evidence.organization_review_receipt_id,
436
+ },
437
+ },
438
+ })),
439
+ context_entities: normalized.context_entities,
440
+ role_min: normalized.role_min,
441
+ sensitivity: normalized.sensitivity,
442
+ evidence: derived,
443
+ };
444
+ }
445
+ export function promotionRequestId(input) {
446
+ return `kpr_${promotionSha256(canonicalJson({
447
+ approval_kind: input.approval_kind,
448
+ source_kind: input.source.kind,
449
+ source_id: input.source.id,
450
+ source_version: input.source.version,
451
+ project_code: input.project_code,
452
+ normalized_payload_hash: input.normalized_payload_hash,
453
+ })).slice(0, 24)}`;
454
+ }
455
+ export class KnowledgePromotionService {
456
+ ports;
457
+ now;
458
+ constructor(ports) {
459
+ if (!ports?.store || !ports.sources || !ports.knowledgeEvents || !ports.graph) {
460
+ throw new Error('KnowledgePromotionService requires store, sources, knowledgeEvents, and graph ports');
461
+ }
462
+ this.ports = ports;
463
+ this.now = ports.now ?? (() => new Date());
464
+ }
465
+ /** D4: request sharing a Personal Knowledge v1 event owned by the authenticated person. */
466
+ async requestPersonalShare(input, context) {
467
+ const principal = requireHuman(context.principal);
468
+ const policy = this.requirePolicy();
469
+ const read = this.ports.sources.readPersonalKnowledgeEvent;
470
+ if (typeof read !== 'function')
471
+ throw new KnowledgePromotionError('knowledge_promotion_personal_source_unavailable', 503);
472
+ const eventId = requireText(input?.personal_event_id, 'knowledge_promotion_source_id_required');
473
+ const projectCode = requireText(input?.project_code, 'knowledge_promotion_project_required');
474
+ return this.ports.store.transaction(async (tx) => {
475
+ const ctx = { tx, principal };
476
+ const event = await read.call(this.ports.sources, eventId, ctx);
477
+ if (!event || event.owner_person_id !== principal.personId || event.organization_id !== principal.organizationId) {
478
+ throw new KnowledgePromotionError('knowledge_promotion_source_not_found', 404);
479
+ }
480
+ requireProjectAccess(principal, projectCode);
481
+ if (!event.active)
482
+ throw new KnowledgePromotionError('knowledge_promotion_source_not_promotable', 409);
483
+ const preview = sanitizePreview(input.summary);
484
+ const normalizedResult = normalizePromotionPayload(input.normalized_payload);
485
+ const source = {
486
+ kind: 'personal_knowledge_v1',
487
+ id: event.event_id,
488
+ version: requireText(event.version, 'knowledge_promotion_source_version_required'),
489
+ evidence_hash: requireText(event.body_hash, 'knowledge_promotion_source_evidence_required'),
490
+ };
491
+ const draft = this.draftRequest({
492
+ approval_kind: 'personal_share',
493
+ source,
494
+ source_event_ids: [event.event_id],
495
+ principal,
496
+ projectCode,
497
+ ownerPersonId: principal.personId,
498
+ status: 'pending_owner_approval',
499
+ subject: sanitizeSubject(input.subject, { type: normalizedResult.normalized.entity.type, id: normalizedResult.normalized.entity.id }),
500
+ preview,
501
+ normalizedResult,
502
+ });
503
+ await this.authorize(policy, 'request', draft, normalizedResult.normalized, principal, context.authority ?? null, ctx);
504
+ await this.claimAuthority(context.authority ?? null, draft, 'request', ctx);
505
+ return this.ports.store.createRequest(draft, ctx);
506
+ }, { principal });
507
+ }
508
+ /** D12: request promoting an organization candidate waiting for the Graph promotion review. */
509
+ async requestOrganizationCandidate(input, context) {
510
+ const principal = requireHuman(context.principal);
511
+ const policy = this.requirePolicy();
512
+ const read = this.ports.sources.readOrganizationCandidate;
513
+ if (typeof read !== 'function')
514
+ throw new KnowledgePromotionError('knowledge_promotion_candidate_source_unavailable', 503);
515
+ if (typeof this.ports.sources.recordOrganizationCandidateOutcome !== 'function') {
516
+ throw new KnowledgePromotionError('knowledge_promotion_candidate_outcome_unavailable', 503);
517
+ }
518
+ const candidateId = requireText(input?.candidate_id, 'knowledge_promotion_source_id_required');
519
+ const projectCode = requireText(input?.project_code, 'knowledge_promotion_project_required');
520
+ return this.ports.store.transaction(async (tx) => {
521
+ const ctx = { tx, principal };
522
+ const candidate = await read.call(this.ports.sources, candidateId, ctx);
523
+ if (!candidate || candidate.organization_id !== principal.organizationId) {
524
+ throw new KnowledgePromotionError('knowledge_promotion_source_not_found', 404);
525
+ }
526
+ if (candidate.project_code !== projectCode) {
527
+ throw new KnowledgePromotionError('knowledge_promotion_source_scope_mismatch', 403);
528
+ }
529
+ requireProjectAccess(principal, projectCode);
530
+ if (!candidate.active)
531
+ throw new KnowledgePromotionError('knowledge_promotion_source_not_promotable', 409);
532
+ const normalizedResult = normalizePromotionPayload(input.normalized_payload);
533
+ const source = {
534
+ kind: 'organization_candidate',
535
+ id: candidate.candidate_id,
536
+ version: requireText(candidate.version, 'knowledge_promotion_source_version_required'),
537
+ evidence_hash: requireText(candidate.evidence_hash, 'knowledge_promotion_source_evidence_required'),
538
+ };
539
+ const draft = this.draftRequest({
540
+ approval_kind: 'organization_candidate',
541
+ source,
542
+ source_event_ids: [...(candidate.source_event_ids ?? [])],
543
+ principal,
544
+ projectCode,
545
+ ownerPersonId: null,
546
+ status: 'pending_org_review',
547
+ subject: { type: normalizedResult.normalized.entity.type, id: normalizedResult.normalized.entity.id },
548
+ preview: null,
549
+ normalizedResult,
550
+ });
551
+ await this.authorize(policy, 'request', draft, normalizedResult.normalized, principal, context.authority ?? null, ctx);
552
+ await this.claimAuthority(context.authority ?? null, draft, 'request', ctx);
553
+ return this.ports.store.createRequest(draft, ctx);
554
+ }, { principal });
555
+ }
556
+ /** D4: the owner approves or rejects sharing the exact normalized content. */
557
+ async decideOwnerConsent(requestId, input, context) {
558
+ const principal = requireHuman(context.principal);
559
+ const policy = this.requirePolicy();
560
+ if (!DECISIONS.has(input?.decision))
561
+ throw new KnowledgePromotionError('knowledge_promotion_decision_invalid');
562
+ return this.ports.store.transaction(async (tx) => {
563
+ const ctx = { tx, principal };
564
+ const request = await this.ports.store.findRequest(requestId, ctx);
565
+ if (!request || request.organization_id !== principal.organizationId) {
566
+ throw new KnowledgePromotionError('knowledge_promotion_request_not_found', 404);
567
+ }
568
+ if (request.approval_kind !== 'personal_share') {
569
+ throw new KnowledgePromotionError('knowledge_promotion_owner_consent_not_applicable', 409);
570
+ }
571
+ if (request.owner_person_id !== principal.personId) {
572
+ throw new KnowledgePromotionError('knowledge_promotion_request_not_found', 404);
573
+ }
574
+ requireProjectAccess(principal, request.project_code);
575
+ const normalized = normalizePromotionPayload(request.normalized_payload).normalized;
576
+ await this.authorize(policy, 'owner_consent', request, normalized, principal, context.authority ?? null, ctx);
577
+ await this.claimAuthority(context.authority ?? null, request, 'owner_consent', ctx);
578
+ if (request.status === 'pending_org_review' && input.decision === 'approve')
579
+ return request;
580
+ if (request.status === 'owner_rejected' && input.decision === 'reject')
581
+ return request;
582
+ if (request.status !== 'pending_owner_approval') {
583
+ throw new KnowledgePromotionError('knowledge_promotion_already_decided', 409);
584
+ }
585
+ const expectedRevision = requireExpectedRevision(input.expected_revision, request);
586
+ const decidedAt = this.now().toISOString();
587
+ const actor = actorOf(principal);
588
+ let receiptId = null;
589
+ if (input.decision === 'approve') {
590
+ if (input.normalized_payload_hash !== request.normalized_payload_hash) {
591
+ throw new KnowledgePromotionError('knowledge_promotion_normalized_payload_hash_mismatch', 409);
592
+ }
593
+ receiptId = ownerConsentReceipt({ ...request, owner_decided_by: actor, owner_decided_at: decidedAt });
594
+ }
595
+ const updated = await this.ports.store.updateRequest(request.request_id, {
596
+ status: input.decision === 'approve' ? 'pending_org_review' : 'owner_rejected',
597
+ owner_decided_by: actor,
598
+ owner_decided_at: decidedAt,
599
+ owner_consent_receipt_id: receiptId,
600
+ decisions: [...request.decisions, {
601
+ action: 'owner_consent', decision: input.decision, actor_person_id: actor, decided_at: decidedAt, receipt_id: receiptId, reason: null,
602
+ }],
603
+ updated_at: decidedAt,
604
+ }, { expectedRevision }, ctx);
605
+ if (!updated)
606
+ throw new KnowledgePromotionError('knowledge_promotion_state_conflict', 409);
607
+ return updated;
608
+ }, { principal });
609
+ }
610
+ /**
611
+ * D4: organization review of an owner-approved personal share.
612
+ * D12: organization approval of an organization candidate.
613
+ * Only an approval commits the event, the Graph mutation, the lineage, and the request in one transaction.
614
+ */
615
+ async reviewOrganization(requestId, input, context) {
616
+ const principal = requireHuman(context.principal);
617
+ const policy = this.requirePolicy();
618
+ if (!DECISIONS.has(input?.decision))
619
+ throw new KnowledgePromotionError('knowledge_promotion_decision_invalid');
620
+ const staleness = { stale: false };
621
+ const result = await this.ports.store.transaction(async (tx) => {
622
+ const ctx = { tx, principal };
623
+ const request = await this.ports.store.findRequest(requestId, ctx);
624
+ if (!request || request.organization_id !== principal.organizationId) {
625
+ throw new KnowledgePromotionError('knowledge_promotion_request_not_found', 404);
626
+ }
627
+ requireProjectAccess(principal, request.project_code);
628
+ const actor = actorOf(principal);
629
+ if (request.approval_kind === 'personal_share' && request.owner_person_id === actor) {
630
+ throw new KnowledgePromotionError('knowledge_promotion_distinct_reviewer_required', 403);
631
+ }
632
+ if (request.status === 'pending_owner_approval' || request.status === 'owner_rejected') {
633
+ throw new KnowledgePromotionError('knowledge_promotion_owner_consent_required', 409);
634
+ }
635
+ const normalizedResult = normalizePromotionPayload(request.normalized_payload);
636
+ if (normalizedResult.normalized_payload_hash !== request.normalized_payload_hash) {
637
+ throw new KnowledgePromotionError('knowledge_promotion_normalized_payload_hash_mismatch', 409);
638
+ }
639
+ if (request.approval_kind === 'personal_share'
640
+ && request.owner_consent_receipt_id !== ownerConsentReceipt(request)) {
641
+ throw new KnowledgePromotionError('knowledge_promotion_owner_consent_receipt_mismatch', 409);
642
+ }
643
+ const review = await this.authorize(policy, 'organization_review', request, normalizedResult.normalized, principal, context.authority ?? null, ctx);
644
+ await this.claimAuthority(context.authority ?? null, request, 'organization_review', ctx);
645
+ if (request.status === 'org_accepted' && input.decision === 'approve')
646
+ return request;
647
+ if (request.status === 'org_rejected' && input.decision === 'reject')
648
+ return request;
649
+ if (TERMINAL.has(request.status))
650
+ throw new KnowledgePromotionError('knowledge_promotion_already_decided', 409);
651
+ const expectedRevision = requireExpectedRevision(input.expected_revision, request);
652
+ const reviewedAt = this.now().toISOString();
653
+ const approver = review.decider_person_id ?? actor;
654
+ const receiptId = request.approval_kind === 'personal_share'
655
+ ? organizationReviewReceipt(request, request.normalized_payload_hash, approver, reviewedAt)
656
+ : candidateApprovalReceipt(request, approver, reviewedAt);
657
+ const decisionRecord = {
658
+ action: 'organization_review', decision: input.decision, actor_person_id: approver, decided_at: reviewedAt,
659
+ receipt_id: receiptId, reason: sanitizeReason(input.reason),
660
+ };
661
+ if (input.decision === 'reject') {
662
+ const rejected = await this.ports.store.updateRequest(request.request_id, {
663
+ status: 'org_rejected',
664
+ organization_review_receipt_id: receiptId,
665
+ decisions: [...request.decisions, decisionRecord],
666
+ updated_at: reviewedAt,
667
+ }, { expectedRevision }, ctx);
668
+ if (!rejected)
669
+ throw new KnowledgePromotionError('knowledge_promotion_state_conflict', 409);
670
+ await this.recordCandidateOutcome(request, 'rejected', approver, receiptId, null, null, reviewedAt, ctx);
671
+ return rejected;
672
+ }
673
+ if (!(await this.sourceIsCurrent(request, ctx))) {
674
+ const stale = await this.ports.store.updateRequest(request.request_id, {
675
+ status: 'source_stale',
676
+ updated_at: reviewedAt,
677
+ }, { expectedRevision }, ctx);
678
+ if (!stale)
679
+ throw new KnowledgePromotionError('knowledge_promotion_state_conflict', 409);
680
+ staleness.stale = true;
681
+ return stale;
682
+ }
683
+ const evidence = buildPromotionEvidence(request, receiptId);
684
+ const organizationEvent = buildOrganizationKnowledgeEvent(request, normalizedResult.normalized, normalizedResult.summary, evidence, approver, reviewedAt);
685
+ const eventResult = await this.ports.knowledgeEvents.recordOrganizationEvent(organizationEvent, ctx);
686
+ if (eventResult?.semantic_state === 'quarantined') {
687
+ throw new KnowledgePromotionError('knowledge_promotion_event_quarantined', 409, { reason: eventResult.quarantine_reason ?? null });
688
+ }
689
+ if (!eventResult?.event_id)
690
+ throw new KnowledgePromotionError('knowledge_promotion_event_readback_failed', 409);
691
+ const mutation = buildNormalizedGraphMutation(request, normalizedResult.normalized, evidence, eventResult, organizationEvent);
692
+ const graphResult = await this.ports.graph.commitNormalizedPromotion(mutation, ctx);
693
+ if (!graphResult?.id)
694
+ throw new KnowledgePromotionError('knowledge_promotion_graph_readback_failed', 409);
695
+ if (eventResult.candidate_id) {
696
+ if (typeof this.ports.knowledgeEvents.reconcileGraphProjection === 'function') {
697
+ await this.ports.knowledgeEvents.reconcileGraphProjection({
698
+ candidate_id: eventResult.candidate_id,
699
+ graph_entity_id: graphResult.id,
700
+ event_id: eventResult.event_id,
701
+ actor_person_id: approver,
702
+ decision_owner_person_id: organizationEvent.decision_authority.decider_id,
703
+ }, ctx);
704
+ }
705
+ }
706
+ const accepted = await this.ports.store.updateRequest(request.request_id, {
707
+ status: 'org_accepted',
708
+ organization_review_receipt_id: receiptId,
709
+ organization_event_id: eventResult.event_id,
710
+ graph_entity_id: graphResult.id,
711
+ decisions: [...request.decisions, decisionRecord],
712
+ updated_at: reviewedAt,
713
+ }, { expectedRevision }, ctx);
714
+ if (!accepted)
715
+ throw new KnowledgePromotionError('knowledge_promotion_state_conflict', 409);
716
+ await this.ports.store.createLineage({
717
+ lineage_id: `kpl_${promotionSha256(`${request.request_id}:${eventResult.event_id}`).slice(0, 24)}`,
718
+ contract_version: KNOWLEDGE_PROMOTION_CONTRACT_VERSION,
719
+ approval_kind: request.approval_kind,
720
+ promotion_request_id: request.request_id,
721
+ source: request.source,
722
+ source_event_ids: request.source_event_ids,
723
+ owner_person_id: request.owner_person_id,
724
+ organization_id: request.organization_id,
725
+ project_code: request.project_code,
726
+ organization_event_id: eventResult.event_id,
727
+ graph_entity_id: graphResult.id,
728
+ normalized_payload_hash: request.normalized_payload_hash,
729
+ receipts: { owner_consent_receipt_id: request.owner_consent_receipt_id, organization_review_receipt_id: receiptId },
730
+ approved_by_person_id: approver,
731
+ sanitization: { raw_copied: false, personal_body_copied: false, sanitized_preview_copied: false },
732
+ created_at: reviewedAt,
733
+ }, ctx);
734
+ await this.recordCandidateOutcome(request, 'promoted', approver, receiptId, graphResult.id, eventResult.event_id, reviewedAt, ctx);
735
+ return accepted;
736
+ }, { principal });
737
+ // The stale state is committed, then reported, so a retry cannot promote an old version.
738
+ if (staleness.stale)
739
+ throw new KnowledgePromotionError('knowledge_promotion_source_stale', 409, { request_id: requestId });
740
+ return result;
741
+ }
742
+ requirePolicy() {
743
+ if (!this.ports.reviewPolicy || typeof this.ports.reviewPolicy.authorize !== 'function') {
744
+ throw new KnowledgePromotionError('knowledge_promotion_review_policy_unavailable', 503);
745
+ }
746
+ return this.ports.reviewPolicy;
747
+ }
748
+ draftRequest(input) {
749
+ const createdAt = this.now().toISOString();
750
+ return {
751
+ request_id: promotionRequestId({
752
+ approval_kind: input.approval_kind,
753
+ source: input.source,
754
+ project_code: input.projectCode,
755
+ normalized_payload_hash: input.normalizedResult.normalized_payload_hash,
756
+ }),
757
+ contract_version: KNOWLEDGE_PROMOTION_CONTRACT_VERSION,
758
+ approval_kind: input.approval_kind,
759
+ source: input.source,
760
+ source_event_ids: input.source_event_ids,
761
+ organization_id: input.principal.organizationId,
762
+ project_code: input.projectCode,
763
+ owner_person_id: input.ownerPersonId,
764
+ requested_by_person_id: actorOf(input.principal),
765
+ status: input.status,
766
+ revision: 0,
767
+ subject: input.subject,
768
+ sanitized_preview: input.preview,
769
+ preview_hash: input.preview === null ? null : `sha256:${promotionSha256(input.preview)}`,
770
+ normalized_payload: input.normalizedResult.normalized,
771
+ normalized_payload_hash: input.normalizedResult.normalized_payload_hash,
772
+ normalization_contract_version: NORMALIZED_PROMOTION_SCHEMA_VERSION,
773
+ owner_decided_by: null,
774
+ owner_decided_at: null,
775
+ owner_consent_receipt_id: null,
776
+ organization_review_receipt_id: null,
777
+ organization_event_id: null,
778
+ graph_entity_id: null,
779
+ decisions: [],
780
+ created_at: createdAt,
781
+ updated_at: createdAt,
782
+ };
783
+ }
784
+ async authorize(policy, action, request, normalized, principal, authority, ctx) {
785
+ const result = await policy.authorize({ action, approvalKind: request.approval_kind, request, normalized, principal, authority }, ctx);
786
+ if (!result || result.allowed !== true) {
787
+ const refusal = result;
788
+ throw new KnowledgePromotionError(refusal?.code || 'knowledge_promotion_review_denied', refusal?.status ?? 403, refusal?.details);
789
+ }
790
+ return result;
791
+ }
792
+ async claimAuthority(authority, request, action, ctx) {
793
+ if (!authority)
794
+ return;
795
+ if (typeof this.ports.store.claimAuthorityUse !== 'function') {
796
+ throw new KnowledgePromotionError('knowledge_promotion_authority_ledger_unavailable', 503);
797
+ }
798
+ // Claim before any idempotent return: a replayed signed authority fails closed.
799
+ await this.ports.store.claimAuthorityUse({
800
+ operation_id: requireText(authority.operationId, 'knowledge_promotion_authority_invalid'),
801
+ idempotency_key: requireText(authority.idempotencyKey, 'knowledge_promotion_authority_invalid'),
802
+ request_id: request.request_id,
803
+ action,
804
+ actor_person_id: actorOf(ctx.principal),
805
+ organization_id: ctx.principal.organizationId,
806
+ project_code: request.project_code,
807
+ }, ctx);
808
+ }
809
+ async sourceIsCurrent(request, ctx) {
810
+ if (request.source.kind === 'personal_knowledge_v1') {
811
+ const read = this.ports.sources.readPersonalKnowledgeEvent;
812
+ if (typeof read !== 'function')
813
+ throw new KnowledgePromotionError('knowledge_promotion_personal_source_unavailable', 503);
814
+ // The reviewer cannot read the owner's private event; the source adapter answers for the request's owner.
815
+ const event = await read.call(this.ports.sources, request.source.id, ctx);
816
+ return Boolean(event && event.active && event.version === request.source.version
817
+ && event.owner_person_id === request.owner_person_id && event.organization_id === request.organization_id);
818
+ }
819
+ const read = this.ports.sources.readOrganizationCandidate;
820
+ if (typeof read !== 'function')
821
+ throw new KnowledgePromotionError('knowledge_promotion_candidate_source_unavailable', 503);
822
+ const candidate = await read.call(this.ports.sources, request.source.id, ctx);
823
+ return Boolean(candidate && candidate.active && candidate.version === request.source.version
824
+ && candidate.organization_id === request.organization_id && candidate.project_code === request.project_code);
825
+ }
826
+ async recordCandidateOutcome(request, outcome, actor, receiptId, graphEntityId, organizationEventId, decidedAt, ctx) {
827
+ if (request.approval_kind !== 'organization_candidate')
828
+ return;
829
+ const record = this.ports.sources.recordOrganizationCandidateOutcome;
830
+ if (typeof record !== 'function')
831
+ throw new KnowledgePromotionError('knowledge_promotion_candidate_outcome_unavailable', 503);
832
+ await record.call(this.ports.sources, {
833
+ candidate_id: request.source.id,
834
+ outcome,
835
+ request_id: request.request_id,
836
+ actor_person_id: actor,
837
+ receipt_id: receiptId,
838
+ graph_entity_id: graphEntityId,
839
+ organization_event_id: organizationEventId,
840
+ decided_at: decidedAt,
841
+ }, ctx);
842
+ }
843
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@unson/brainbase-mcp",
3
- "version": "0.10.11",
3
+ "version": "0.10.12",
4
4
  "description": "Local-first Brainbase judgment OS that lets supported AI agents reuse a user's approved goals, priorities, decisions, and judgment criteria.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -155,6 +155,10 @@
155
155
  "types": "./dist/personal-knowledge.d.ts",
156
156
  "import": "./dist/personal-knowledge.js"
157
157
  },
158
+ "./knowledge-promotion": {
159
+ "types": "./dist/knowledge-promotion.d.ts",
160
+ "import": "./dist/knowledge-promotion.js"
161
+ },
158
162
  "./organization-connection": {
159
163
  "types": "./dist/organization-connection.d.ts",
160
164
  "import": "./dist/organization-connection.js"
@@ -336,5 +340,5 @@
336
340
  "engines": {
337
341
  "node": ">=20"
338
342
  },
339
- "gitHead": "6e626178bfa8b8b3acf99600ba79555309518046"
343
+ "gitHead": "10493d41000c832bf85c33fc30e920a4c194fe61"
340
344
  }