@siduri-x/core 2.0.0 → 2.0.2

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.
package/dist/siduri-db.js CHANGED
@@ -114,7 +114,9 @@ class SiduriDatabase {
114
114
  valid_from TEXT,
115
115
  valid_until TEXT,
116
116
  evidence TEXT,
117
- asserted_at TEXT DEFAULT (datetime('now'))
117
+ asserted_at TEXT DEFAULT (datetime('now')),
118
+ supersedes TEXT,
119
+ source_event_id TEXT
118
120
  );
119
121
 
120
122
  -- FTS5 Virtual Table for Memory Claims
@@ -145,6 +147,18 @@ class SiduriDatabase {
145
147
  END;
146
148
  `;
147
149
  this.db.exec(schema);
150
+ try {
151
+ this.db.exec("ALTER TABLE memory_claims ADD COLUMN supersedes TEXT");
152
+ }
153
+ catch {
154
+ // Column already exists
155
+ }
156
+ try {
157
+ this.db.exec("ALTER TABLE memory_claims ADD COLUMN source_event_id TEXT");
158
+ }
159
+ catch {
160
+ // Column already exists
161
+ }
148
162
  }
149
163
  close() {
150
164
  this.db.close();
@@ -228,9 +242,106 @@ class SiduriDatabase {
228
242
  `);
229
243
  stmt.run(directive.id, directive.companionId, directive.priority, directive.directive, directive.status, directive.category, directive.supersedesId || null, directive.createdAt || new Date().toISOString());
230
244
  }
231
- disableDirective(id) {
232
- const stmt = this.db.prepare("UPDATE self_directives SET status = 'DISABLED' WHERE id = ?");
233
- stmt.run(id);
245
+ getDirective(id, companionId) {
246
+ const stmt = companionId
247
+ ? this.db.prepare('SELECT * FROM self_directives WHERE id = ? AND companion_id = ?')
248
+ : this.db.prepare('SELECT * FROM self_directives WHERE id = ?');
249
+ const row = (companionId ? stmt.get(id, companionId) : stmt.get(id));
250
+ if (!row)
251
+ return undefined;
252
+ return {
253
+ id: row.id,
254
+ companionId: row.companion_id,
255
+ priority: row.priority,
256
+ directive: row.directive,
257
+ status: row.status,
258
+ category: row.category,
259
+ supersedesId: row.supersedes_id || undefined,
260
+ createdAt: row.created_at,
261
+ };
262
+ }
263
+ approveDirective(id, companionId) {
264
+ const findStmt = companionId
265
+ ? this.db.prepare("SELECT id, companion_id, status, supersedes_id FROM self_directives WHERE id = ? AND companion_id = ?")
266
+ : this.db.prepare("SELECT id, companion_id, status, supersedes_id FROM self_directives WHERE id = ?");
267
+ const row = (companionId ? findStmt.get(id, companionId) : findStmt.get(id));
268
+ if (!row) {
269
+ return;
270
+ }
271
+ if (row.status !== 'PENDING') {
272
+ throw new Error(`Cannot approve directive '${id}': invalid transition from status '${row.status}' to 'ACTIVE' (only PENDING directives can be approved)`);
273
+ }
274
+ // If this directive supersedes an earlier directive, transition that prior directive to SUPERSEDED
275
+ if (row.supersedes_id) {
276
+ const supersededId = row.supersedes_id;
277
+ const effectiveCompanionId = companionId || row.companion_id;
278
+ if (effectiveCompanionId) {
279
+ const supersedeStmt = this.db.prepare("UPDATE self_directives SET status = 'SUPERSEDED' WHERE id = ? AND companion_id = ?");
280
+ supersedeStmt.run(supersededId, effectiveCompanionId);
281
+ }
282
+ else {
283
+ const supersedeStmt = this.db.prepare("UPDATE self_directives SET status = 'SUPERSEDED' WHERE id = ?");
284
+ supersedeStmt.run(supersededId);
285
+ }
286
+ }
287
+ if (companionId) {
288
+ const stmt = this.db.prepare("UPDATE self_directives SET status = 'ACTIVE' WHERE id = ? AND companion_id = ? AND status = 'PENDING'");
289
+ stmt.run(id, companionId);
290
+ }
291
+ else {
292
+ const stmt = this.db.prepare("UPDATE self_directives SET status = 'ACTIVE' WHERE id = ? AND status = 'PENDING'");
293
+ stmt.run(id);
294
+ }
295
+ }
296
+ rejectDirective(id, companionId) {
297
+ const findStmt = companionId
298
+ ? this.db.prepare("SELECT id, companion_id, status FROM self_directives WHERE id = ? AND companion_id = ?")
299
+ : this.db.prepare("SELECT id, companion_id, status FROM self_directives WHERE id = ?");
300
+ const row = (companionId ? findStmt.get(id, companionId) : findStmt.get(id));
301
+ if (!row) {
302
+ return;
303
+ }
304
+ if (row.status !== 'PENDING') {
305
+ throw new Error(`Cannot reject directive '${id}': invalid transition from status '${row.status}' to 'REJECTED' (only PENDING directives can be rejected)`);
306
+ }
307
+ if (companionId) {
308
+ const stmt = this.db.prepare("UPDATE self_directives SET status = 'REJECTED' WHERE id = ? AND companion_id = ? AND status = 'PENDING'");
309
+ stmt.run(id, companionId);
310
+ }
311
+ else {
312
+ const stmt = this.db.prepare("UPDATE self_directives SET status = 'REJECTED' WHERE id = ? AND status = 'PENDING'");
313
+ stmt.run(id);
314
+ }
315
+ }
316
+ revokeDirective(id, companionId) {
317
+ if (companionId) {
318
+ const stmt = this.db.prepare("UPDATE self_directives SET status = 'REVOKED' WHERE id = ? AND companion_id = ?");
319
+ stmt.run(id, companionId);
320
+ }
321
+ else {
322
+ const stmt = this.db.prepare("UPDATE self_directives SET status = 'REVOKED' WHERE id = ?");
323
+ stmt.run(id);
324
+ }
325
+ }
326
+ expireDirective(id, companionId) {
327
+ if (companionId) {
328
+ const stmt = this.db.prepare("UPDATE self_directives SET status = 'EXPIRED' WHERE id = ? AND companion_id = ?");
329
+ stmt.run(id, companionId);
330
+ }
331
+ else {
332
+ const stmt = this.db.prepare("UPDATE self_directives SET status = 'EXPIRED' WHERE id = ?");
333
+ stmt.run(id);
334
+ }
335
+ }
336
+ disableDirective(id, companionId) {
337
+ if (companionId) {
338
+ const stmt = this.db.prepare("UPDATE self_directives SET status = 'DISABLED' WHERE id = ? AND companion_id = ?");
339
+ stmt.run(id, companionId);
340
+ }
341
+ else {
342
+ const stmt = this.db.prepare("UPDATE self_directives SET status = 'DISABLED' WHERE id = ?");
343
+ stmt.run(id);
344
+ }
234
345
  }
235
346
  getRelationship(companionId, entityId) {
236
347
  const stmt = this.db.prepare('SELECT * FROM self_relationships WHERE companion_id = ? AND entity_id = ?');
@@ -380,33 +491,116 @@ class SiduriDatabase {
380
491
  payload: JSON.parse(row.payload)
381
492
  }));
382
493
  }
494
+ getEvent(id) {
495
+ const stmt = this.db.prepare('SELECT * FROM memory_events WHERE id = ?');
496
+ const row = stmt.get(id);
497
+ if (!row)
498
+ return undefined;
499
+ return {
500
+ id: row.id,
501
+ companionId: row.companion_id,
502
+ sourceType: row.source_type,
503
+ occurredAt: row.occurred_at,
504
+ payload: JSON.parse(row.payload)
505
+ };
506
+ }
383
507
  proposeClaim(claim) {
384
508
  const id = claim.id || crypto.randomUUID();
385
509
  const status = 'PENDING';
510
+ const confidence = claim.confidence ?? 1.0;
511
+ const assertedAt = claim.assertedAt || new Date().toISOString();
386
512
  const stmt = this.db.prepare(`
387
- INSERT INTO memory_claims (id, companion_id, subject, predicate, value, status, confidence, valid_from, valid_until, evidence, asserted_at)
388
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, coalesce(?, datetime('now')))
513
+ INSERT INTO memory_claims (id, companion_id, subject, predicate, value, status, confidence, valid_from, valid_until, evidence, asserted_at, supersedes, source_event_id)
514
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, coalesce(?, datetime('now')), ?, ?)
389
515
  `);
390
- stmt.run(id, claim.companionId, claim.subject, claim.predicate, claim.value, status, claim.confidence ?? 1.0, claim.validFrom || null, claim.validUntil || null, claim.evidence ? JSON.stringify(claim.evidence) : null, claim.assertedAt || null);
516
+ stmt.run(id, claim.companionId, claim.subject, claim.predicate, claim.value, status, confidence, claim.validFrom || null, claim.validUntil || null, claim.evidence ? JSON.stringify(claim.evidence) : null, assertedAt || null, claim.supersedes || null, claim.sourceEventId || null);
391
517
  return {
392
518
  ...claim,
393
519
  id,
394
- status
520
+ status,
521
+ confidence,
522
+ assertedAt,
523
+ supersedes: claim.supersedes,
524
+ sourceEventId: claim.sourceEventId,
395
525
  };
396
526
  }
397
- approveClaim(id) {
398
- const stmt = this.db.prepare("UPDATE memory_claims SET status = 'APPROVED' WHERE id = ?");
399
- stmt.run(id);
527
+ approveClaim(id, companionId) {
528
+ const findClaim = companionId
529
+ ? this.db.prepare("SELECT id, status, supersedes FROM memory_claims WHERE id = ? AND companion_id = ?")
530
+ : this.db.prepare("SELECT id, status, supersedes FROM memory_claims WHERE id = ?");
531
+ const row = (companionId ? findClaim.get(id, companionId) : findClaim.get(id));
532
+ if (!row) {
533
+ return;
534
+ }
535
+ if (row.status !== 'PENDING') {
536
+ throw new Error(`Cannot approve claim '${id}': invalid transition from status '${row.status}' to 'APPROVED' (only PENDING claims can be approved)`);
537
+ }
538
+ // If this claim supersedes an earlier claim, transition that prior claim to SUPERSEDED
539
+ if (row.supersedes) {
540
+ const supersededId = row.supersedes;
541
+ if (companionId) {
542
+ const supersedeStmt = this.db.prepare("UPDATE memory_claims SET status = 'SUPERSEDED' WHERE id = ? AND companion_id = ?");
543
+ supersedeStmt.run(supersededId, companionId);
544
+ }
545
+ else {
546
+ const supersedeStmt = this.db.prepare("UPDATE memory_claims SET status = 'SUPERSEDED' WHERE id = ?");
547
+ supersedeStmt.run(supersededId);
548
+ }
549
+ }
550
+ if (companionId) {
551
+ const stmt = this.db.prepare("UPDATE memory_claims SET status = 'APPROVED' WHERE id = ? AND companion_id = ? AND status = 'PENDING'");
552
+ stmt.run(id, companionId);
553
+ }
554
+ else {
555
+ const stmt = this.db.prepare("UPDATE memory_claims SET status = 'APPROVED' WHERE id = ? AND status = 'PENDING'");
556
+ stmt.run(id);
557
+ }
400
558
  }
401
- rejectClaim(id) {
402
- const stmt = this.db.prepare("UPDATE memory_claims SET status = 'REJECTED' WHERE id = ?");
403
- stmt.run(id);
559
+ rejectClaim(id, companionId) {
560
+ if (companionId) {
561
+ const stmt = this.db.prepare("UPDATE memory_claims SET status = 'REJECTED' WHERE id = ? AND companion_id = ?");
562
+ stmt.run(id, companionId);
563
+ }
564
+ else {
565
+ const stmt = this.db.prepare("UPDATE memory_claims SET status = 'REJECTED' WHERE id = ?");
566
+ stmt.run(id);
567
+ }
568
+ }
569
+ revokeClaim(id, companionId) {
570
+ if (companionId) {
571
+ const stmt = this.db.prepare("UPDATE memory_claims SET status = 'REVOKED' WHERE id = ? AND companion_id = ?");
572
+ stmt.run(id, companionId);
573
+ }
574
+ else {
575
+ const stmt = this.db.prepare("UPDATE memory_claims SET status = 'REVOKED' WHERE id = ?");
576
+ stmt.run(id);
577
+ }
578
+ }
579
+ expireClaim(id, companionId) {
580
+ if (companionId) {
581
+ const stmt = this.db.prepare("UPDATE memory_claims SET status = 'EXPIRED' WHERE id = ? AND companion_id = ?");
582
+ stmt.run(id, companionId);
583
+ }
584
+ else {
585
+ const stmt = this.db.prepare("UPDATE memory_claims SET status = 'EXPIRED' WHERE id = ?");
586
+ stmt.run(id);
587
+ }
588
+ }
589
+ markClaimSessionOnly(id, companionId) {
590
+ if (companionId) {
591
+ const stmt = this.db.prepare("UPDATE memory_claims SET status = 'SESSION_ONLY' WHERE id = ? AND companion_id = ?");
592
+ stmt.run(id, companionId);
593
+ }
594
+ else {
595
+ const stmt = this.db.prepare("UPDATE memory_claims SET status = 'SESSION_ONLY' WHERE id = ?");
596
+ stmt.run(id);
597
+ }
404
598
  }
405
599
  searchClaims(companionId, query, limit = 20) {
406
600
  const stmt = this.db.prepare(`
407
601
  SELECT c.* FROM memory_claims c
408
602
  JOIN memory_search s ON c.rowid = s.rowid
409
- WHERE c.companion_id = ? AND memory_search MATCH ?
603
+ WHERE c.companion_id = ? AND c.status = 'APPROVED' AND memory_search MATCH ?
410
604
  ORDER BY rank
411
605
  LIMIT ?
412
606
  `);
@@ -421,7 +615,27 @@ class SiduriDatabase {
421
615
  validFrom: row.valid_from || undefined,
422
616
  validUntil: row.valid_until || undefined,
423
617
  evidence: row.evidence ? JSON.parse(row.evidence) : undefined,
424
- assertedAt: row.asserted_at
618
+ assertedAt: row.asserted_at,
619
+ supersedes: row.supersedes || undefined,
620
+ sourceEventId: row.source_event_id || undefined,
621
+ }));
622
+ }
623
+ getPendingClaims(companionId, limit = 50) {
624
+ const stmt = this.db.prepare("SELECT * FROM memory_claims WHERE companion_id = ? AND status = 'PENDING' ORDER BY asserted_at DESC LIMIT ?");
625
+ return stmt.all(companionId, limit).map((row) => ({
626
+ id: row.id,
627
+ companionId: row.companion_id,
628
+ subject: row.subject,
629
+ predicate: row.predicate,
630
+ value: row.value,
631
+ status: row.status,
632
+ confidence: row.confidence,
633
+ validFrom: row.valid_from || undefined,
634
+ validUntil: row.valid_until || undefined,
635
+ evidence: row.evidence ? JSON.parse(row.evidence) : undefined,
636
+ assertedAt: row.asserted_at,
637
+ supersedes: row.supersedes || undefined,
638
+ sourceEventId: row.source_event_id || undefined,
425
639
  }));
426
640
  }
427
641
  getApprovedClaims(companionId, limit = 50) {
@@ -437,8 +651,37 @@ class SiduriDatabase {
437
651
  validFrom: row.valid_from || undefined,
438
652
  validUntil: row.valid_until || undefined,
439
653
  evidence: row.evidence ? JSON.parse(row.evidence) : undefined,
440
- assertedAt: row.asserted_at
654
+ assertedAt: row.asserted_at,
655
+ supersedes: row.supersedes || undefined,
656
+ sourceEventId: row.source_event_id || undefined,
441
657
  }));
442
658
  }
659
+ getClaim(id) {
660
+ const stmt = this.db.prepare("SELECT * FROM memory_claims WHERE id = ?");
661
+ const row = stmt.get(id);
662
+ if (!row)
663
+ return undefined;
664
+ return {
665
+ id: row.id,
666
+ companionId: row.companion_id,
667
+ subject: row.subject,
668
+ predicate: row.predicate,
669
+ value: row.value,
670
+ status: row.status,
671
+ confidence: row.confidence,
672
+ validFrom: row.valid_from || undefined,
673
+ validUntil: row.valid_until || undefined,
674
+ evidence: row.evidence ? JSON.parse(row.evidence) : undefined,
675
+ assertedAt: row.asserted_at,
676
+ supersedes: row.supersedes || undefined,
677
+ sourceEventId: row.source_event_id || undefined,
678
+ };
679
+ }
680
+ resetMemory(companionId) {
681
+ const deleteClaims = this.db.prepare("DELETE FROM memory_claims WHERE companion_id = ?");
682
+ deleteClaims.run(companionId);
683
+ const deleteEvents = this.db.prepare("DELETE FROM memory_events WHERE companion_id = ?");
684
+ deleteEvents.run(companionId);
685
+ }
443
686
  }
444
687
  exports.SiduriDatabase = SiduriDatabase;
@@ -72,6 +72,16 @@ describe('SiduriDatabase', () => {
72
72
  memDb.close();
73
73
  }).not.toThrow();
74
74
  });
75
+ it('initializes schema and WAL mode within the startup latency budget (<1000ms in CI, typical <20ms locally)', () => {
76
+ const start = performance.now();
77
+ const benchDb = new siduri_db_1.SiduriDatabase({ dbPath });
78
+ const duration = performance.now() - start;
79
+ benchDb.close();
80
+ // In bare-metal local development, SQLite cold init is ~2-5ms.
81
+ // Under virtualized CI runners with concurrent Turbo tasks and shared I/O, allow up to 1000ms.
82
+ const budgetMs = process.env.CI ? 1000 : 250;
83
+ expect(duration).toBeLessThan(budgetMs);
84
+ });
75
85
  it('stores and retrieves companion identity', () => {
76
86
  db = new siduri_db_1.SiduriDatabase({ dbPath });
77
87
  const identity = {
@@ -465,6 +475,91 @@ describe('SiduriDatabase', () => {
465
475
  // More occurrences of "dark" → better BM25 rank (lower rank value = better match)
466
476
  expect(results[0].id).toBe(c1.id);
467
477
  });
478
+ it('excludes pending and rejected claims from searchClaims in SiduriDatabase', () => {
479
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
480
+ const pending = db.proposeClaim({
481
+ id: crypto.randomUUID(),
482
+ companionId: 'siduri-test',
483
+ subject: 'SecretProject',
484
+ predicate: 'status',
485
+ value: 'unapproved draft specification',
486
+ confidence: 0.9,
487
+ assertedAt: new Date().toISOString(),
488
+ });
489
+ // Must not match while pending
490
+ let results = db.searchClaims('siduri-test', 'unapproved');
491
+ expect(results.some((r) => r.id === pending.id)).toBe(false);
492
+ // Approve: now it matches
493
+ db.approveClaim(pending.id, 'siduri-test');
494
+ results = db.searchClaims('siduri-test', 'unapproved');
495
+ expect(results.some((r) => r.id === pending.id)).toBe(true);
496
+ // Revoke: must no longer match
497
+ db.revokeClaim(pending.id, 'siduri-test');
498
+ results = db.searchClaims('siduri-test', 'unapproved');
499
+ expect(results.some((r) => r.id === pending.id)).toBe(false);
500
+ // Explicitly rejected claims must also not match
501
+ const rejected = db.proposeClaim({
502
+ id: crypto.randomUUID(),
503
+ companionId: 'siduri-test',
504
+ subject: 'RejectedFact',
505
+ predicate: 'status',
506
+ value: 'unapproved rejection draft',
507
+ confidence: 0.1,
508
+ });
509
+ db.rejectClaim(rejected.id, 'siduri-test');
510
+ results = db.searchClaims('siduri-test', 'rejection');
511
+ expect(results.some((r) => r.id === rejected.id)).toBe(false);
512
+ });
513
+ it('bounds approveClaim to specific companionId when provided', () => {
514
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
515
+ const claim = db.proposeClaim({
516
+ id: crypto.randomUUID(),
517
+ companionId: 'companion-target',
518
+ subject: 'ProtectedFact',
519
+ predicate: 'belongsTo',
520
+ value: 'Target',
521
+ confidence: 1.0,
522
+ assertedAt: new Date().toISOString(),
523
+ });
524
+ // Attempting to approve for a different companion must not affect it
525
+ db.approveClaim(claim.id, 'companion-intruder');
526
+ expect(db.getApprovedClaims('companion-target')).toHaveLength(0);
527
+ // Approving with the correct companionId succeeds
528
+ db.approveClaim(claim.id, 'companion-target');
529
+ expect(db.getApprovedClaims('companion-target')).toHaveLength(1);
530
+ });
531
+ it('resets memory for the specified companion only', () => {
532
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
533
+ const c1 = db.proposeClaim({
534
+ id: crypto.randomUUID(),
535
+ companionId: 'comp-1',
536
+ subject: 'Fact1',
537
+ predicate: 'is',
538
+ value: 'One',
539
+ confidence: 1.0,
540
+ });
541
+ const c2 = db.proposeClaim({
542
+ id: crypto.randomUUID(),
543
+ companionId: 'comp-2',
544
+ subject: 'Fact2',
545
+ predicate: 'is',
546
+ value: 'Two',
547
+ confidence: 1.0,
548
+ });
549
+ db.approveClaim(c1.id, 'comp-1');
550
+ db.approveClaim(c2.id, 'comp-2');
551
+ db.recordEvent({
552
+ id: crypto.randomUUID(),
553
+ companionId: 'comp-1',
554
+ sourceType: 'chat_turn',
555
+ occurredAt: new Date().toISOString(),
556
+ payload: { text: 'Hello' },
557
+ });
558
+ db.resetMemory('comp-1');
559
+ expect(db.getApprovedClaims('comp-1')).toHaveLength(0);
560
+ expect(db.getRecentEvents('comp-1')).toHaveLength(0);
561
+ expect(db.getApprovedClaims('comp-2')).toHaveLength(1);
562
+ });
468
563
  });
469
564
  // ==========================================
470
565
  // Cross-Domain & Persistence Tests
@@ -576,5 +671,194 @@ describe('SiduriDatabase', () => {
576
671
  expect(db.searchClaims(cId, 'anything')).toHaveLength(0);
577
672
  expect(db.getApprovedClaims(cId)).toHaveLength(0);
578
673
  });
674
+ it('enforces directive state transitions (pending -> active -> disabled/rejected/revoked)', () => {
675
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
676
+ const cId = 'directive-state-test';
677
+ const dId = 'dir-lifecycle-1';
678
+ // 1. Commit directive in PENDING state
679
+ db.commitDirective({
680
+ id: dId,
681
+ companionId: cId,
682
+ priority: 60,
683
+ directive: 'Always verify claims',
684
+ status: 'PENDING',
685
+ category: 'behavioral',
686
+ });
687
+ // Pending directives must not be returned by getActiveDirectives
688
+ expect(db.getActiveDirectives(cId)).toHaveLength(0);
689
+ // 2. Approve directive
690
+ db.approveDirective(dId);
691
+ const active = db.getActiveDirectives(cId);
692
+ expect(active).toHaveLength(1);
693
+ expect(active[0].id).toBe(dId);
694
+ expect(active[0].status).toBe('ACTIVE');
695
+ // 3. Revoke directive
696
+ db.revokeDirective(dId);
697
+ expect(db.getActiveDirectives(cId)).toHaveLength(0);
698
+ // 4. Reject directive
699
+ const d2Id = 'dir-lifecycle-2';
700
+ db.commitDirective({
701
+ id: d2Id,
702
+ companionId: cId,
703
+ priority: 50,
704
+ directive: 'Unsafe rule',
705
+ status: 'PENDING',
706
+ category: 'behavioral',
707
+ });
708
+ db.rejectDirective(d2Id);
709
+ expect(db.getActiveDirectives(cId)).toHaveLength(0);
710
+ });
711
+ it('rejects invalid directive state transitions (throws when approving non-PENDING directive)', () => {
712
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
713
+ const cId = 'directive-invalid-transition-test';
714
+ // 1. Commit directive in REJECTED state
715
+ db.commitDirective({
716
+ id: 'dir-rejected-1',
717
+ companionId: cId,
718
+ priority: 50,
719
+ directive: 'Rejected directive',
720
+ status: 'REJECTED',
721
+ category: 'behavioral',
722
+ });
723
+ expect(() => db.approveDirective('dir-rejected-1')).toThrow(/invalid transition from status 'REJECTED' to 'ACTIVE'/);
724
+ // 2. Commit directive in ACTIVE state
725
+ db.commitDirective({
726
+ id: 'dir-active-1',
727
+ companionId: cId,
728
+ priority: 50,
729
+ directive: 'Already active directive',
730
+ status: 'ACTIVE',
731
+ category: 'behavioral',
732
+ });
733
+ expect(() => db.approveDirective('dir-active-1')).toThrow(/invalid transition from status 'ACTIVE' to 'ACTIVE'/);
734
+ // 3. Rejecting an already ACTIVE directive throws
735
+ expect(() => db.rejectDirective('dir-active-1')).toThrow(/invalid transition from status 'ACTIVE' to 'REJECTED'/);
736
+ });
737
+ it('automatically marks prior directive as SUPERSEDED when approving superseding directive', () => {
738
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
739
+ const cId = 'directive-supersede-test';
740
+ // 1. Initial directive active
741
+ db.commitDirective({
742
+ id: 'dir-original-1',
743
+ companionId: cId,
744
+ priority: 50,
745
+ directive: 'Original rule',
746
+ status: 'ACTIVE',
747
+ category: 'behavioral',
748
+ });
749
+ expect(db.getActiveDirectives(cId)).toHaveLength(1);
750
+ // 2. Propose a superseding directive
751
+ db.commitDirective({
752
+ id: 'dir-replacement-1',
753
+ companionId: cId,
754
+ priority: 55,
755
+ directive: 'Updated replacement rule',
756
+ status: 'PENDING',
757
+ category: 'behavioral',
758
+ supersedesId: 'dir-original-1',
759
+ });
760
+ // Original is still active, replacement is pending
761
+ expect(db.getActiveDirectives(cId)).toHaveLength(1);
762
+ expect(db.getActiveDirectives(cId)[0].id).toBe('dir-original-1');
763
+ // 3. Approve replacement directive
764
+ db.approveDirective('dir-replacement-1', cId);
765
+ const active = db.getActiveDirectives(cId);
766
+ expect(active).toHaveLength(1);
767
+ expect(active[0].id).toBe('dir-replacement-1');
768
+ const original = db.getDirective('dir-original-1', cId);
769
+ expect(original?.status).toBe('SUPERSEDED');
770
+ });
771
+ it('enforces companion isolation on directive approval', () => {
772
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
773
+ const cIdA = 'companion-alpha';
774
+ const cIdB = 'companion-beta';
775
+ db.commitDirective({
776
+ id: 'dir-beta-1',
777
+ companionId: cIdB,
778
+ priority: 50,
779
+ directive: 'Beta private rule',
780
+ status: 'PENDING',
781
+ category: 'behavioral',
782
+ });
783
+ // Alpha attempts to approve Beta's directive scoped to Alpha
784
+ db.approveDirective('dir-beta-1', cIdA);
785
+ // Beta's directive must remain PENDING and unapproved
786
+ const betaDirective = db.getDirective('dir-beta-1', cIdB);
787
+ expect(betaDirective?.status).toBe('PENDING');
788
+ expect(db.getActiveDirectives(cIdB)).toHaveLength(0);
789
+ // Beta approves its own directive successfully
790
+ db.approveDirective('dir-beta-1', cIdB);
791
+ expect(db.getActiveDirectives(cIdB)).toHaveLength(1);
792
+ });
793
+ it('enforces claim state transitions (pending -> approved -> revoked/expired/session_only)', () => {
794
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
795
+ const cId = 'claim-state-test';
796
+ const claim = db.proposeClaim({
797
+ id: 'claim-1',
798
+ companionId: cId,
799
+ subject: 'user',
800
+ predicate: 'likes',
801
+ value: 'matcha',
802
+ });
803
+ expect(claim.status).toBe('PENDING');
804
+ expect(db.getApprovedClaims(cId)).toHaveLength(0);
805
+ // Approve
806
+ db.approveClaim('claim-1');
807
+ expect(db.getApprovedClaims(cId)).toHaveLength(1);
808
+ // Revoke
809
+ db.revokeClaim('claim-1');
810
+ expect(db.getApprovedClaims(cId)).toHaveLength(0);
811
+ // Session only
812
+ const claim2 = db.proposeClaim({
813
+ id: 'claim-2',
814
+ companionId: cId,
815
+ subject: 'session',
816
+ predicate: 'topic',
817
+ value: 'investigation',
818
+ });
819
+ db.markClaimSessionOnly('claim-2');
820
+ expect(db.getApprovedClaims(cId)).toHaveLength(0);
821
+ // Expire
822
+ db.expireClaim('claim-2');
823
+ expect(db.getApprovedClaims(cId)).toHaveLength(0);
824
+ });
825
+ it('strictly rejects illegal claim state transitions in approveClaim', () => {
826
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
827
+ const cId = 'claim-transition-test';
828
+ // 1. Propose and reject claim
829
+ const rejectedClaim = db.proposeClaim({
830
+ id: 'claim-rejected',
831
+ companionId: cId,
832
+ subject: 'fact',
833
+ predicate: 'is',
834
+ value: 'false',
835
+ });
836
+ db.rejectClaim(rejectedClaim.id);
837
+ // Attempting to approve a REJECTED claim must throw
838
+ expect(() => db.approveClaim(rejectedClaim.id)).toThrow(/invalid transition from status 'REJECTED' to 'APPROVED'/);
839
+ // 2. Propose and approve claim, then revoke
840
+ const revokedClaim = db.proposeClaim({
841
+ id: 'claim-revoked',
842
+ companionId: cId,
843
+ subject: 'fact',
844
+ predicate: 'is',
845
+ value: 'outdated',
846
+ });
847
+ db.approveClaim(revokedClaim.id);
848
+ db.revokeClaim(revokedClaim.id);
849
+ // Attempting to approve a REVOKED claim must throw
850
+ expect(() => db.approveClaim(revokedClaim.id)).toThrow(/invalid transition from status 'REVOKED' to 'APPROVED'/);
851
+ // 3. Propose and expire claim
852
+ const expiredClaim = db.proposeClaim({
853
+ id: 'claim-expired',
854
+ companionId: cId,
855
+ subject: 'fact',
856
+ predicate: 'is',
857
+ value: 'temporary',
858
+ });
859
+ db.expireClaim(expiredClaim.id);
860
+ // Attempting to approve an EXPIRED claim must throw
861
+ expect(() => db.approveClaim(expiredClaim.id)).toThrow(/invalid transition from status 'EXPIRED' to 'APPROVED'/);
862
+ });
579
863
  });
580
864
  });
@@ -1,5 +1,5 @@
1
1
  import { ActionAuditEvent } from './action';
2
- import { ActionStore, PersistentExecutionRecord } from './capability';
2
+ import { ActionStore, PersistentExecutionRecord, ActionApprovalRecord } from './capability';
3
3
  export interface SqliteActionStoreOptions {
4
4
  dbPath?: string;
5
5
  }
@@ -12,8 +12,9 @@ export declare class SqliteActionStore implements ActionStore {
12
12
  reserveExecution(record: PersistentExecutionRecord): Promise<boolean>;
13
13
  updateExecution(record: PersistentExecutionRecord): Promise<void>;
14
14
  getExecution(executionId: string): Promise<PersistentExecutionRecord | undefined>;
15
- saveApproval(executionId: string, approverActorId: string, reason?: string): Promise<void>;
15
+ saveApproval(executionId: string, approverActorId: string, reason?: string, approverRole?: string, toolName?: string, parametersHash?: string, companionId?: string, actorId?: string): Promise<void>;
16
16
  isActionApproved(executionId: string): Promise<boolean>;
17
+ getApproval(executionId: string): Promise<ActionApprovalRecord | undefined>;
17
18
  appendAudit(event: ActionAuditEvent): Promise<void>;
18
19
  getAuditLog(executionId?: string): Promise<ActionAuditEvent[]>;
19
20
  close(): void;