@siduri-x/core 2.0.0 → 2.0.1

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.
@@ -72,6 +72,15 @@ describe('SiduriDatabase', () => {
72
72
  memDb.close();
73
73
  }).not.toThrow();
74
74
  });
75
+ it('initializes schema and WAL mode within the startup latency budget (<100ms 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, allow a safe 100ms budget.
82
+ expect(duration).toBeLessThan(100);
83
+ });
75
84
  it('stores and retrieves companion identity', () => {
76
85
  db = new siduri_db_1.SiduriDatabase({ dbPath });
77
86
  const identity = {
@@ -465,6 +474,91 @@ describe('SiduriDatabase', () => {
465
474
  // More occurrences of "dark" → better BM25 rank (lower rank value = better match)
466
475
  expect(results[0].id).toBe(c1.id);
467
476
  });
477
+ it('excludes pending and rejected claims from searchClaims in SiduriDatabase', () => {
478
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
479
+ const pending = db.proposeClaim({
480
+ id: crypto.randomUUID(),
481
+ companionId: 'siduri-test',
482
+ subject: 'SecretProject',
483
+ predicate: 'status',
484
+ value: 'unapproved draft specification',
485
+ confidence: 0.9,
486
+ assertedAt: new Date().toISOString(),
487
+ });
488
+ // Must not match while pending
489
+ let results = db.searchClaims('siduri-test', 'unapproved');
490
+ expect(results.some((r) => r.id === pending.id)).toBe(false);
491
+ // Approve: now it matches
492
+ db.approveClaim(pending.id, 'siduri-test');
493
+ results = db.searchClaims('siduri-test', 'unapproved');
494
+ expect(results.some((r) => r.id === pending.id)).toBe(true);
495
+ // Revoke: must no longer match
496
+ db.revokeClaim(pending.id, 'siduri-test');
497
+ results = db.searchClaims('siduri-test', 'unapproved');
498
+ expect(results.some((r) => r.id === pending.id)).toBe(false);
499
+ // Explicitly rejected claims must also not match
500
+ const rejected = db.proposeClaim({
501
+ id: crypto.randomUUID(),
502
+ companionId: 'siduri-test',
503
+ subject: 'RejectedFact',
504
+ predicate: 'status',
505
+ value: 'unapproved rejection draft',
506
+ confidence: 0.1,
507
+ });
508
+ db.rejectClaim(rejected.id, 'siduri-test');
509
+ results = db.searchClaims('siduri-test', 'rejection');
510
+ expect(results.some((r) => r.id === rejected.id)).toBe(false);
511
+ });
512
+ it('bounds approveClaim to specific companionId when provided', () => {
513
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
514
+ const claim = db.proposeClaim({
515
+ id: crypto.randomUUID(),
516
+ companionId: 'companion-target',
517
+ subject: 'ProtectedFact',
518
+ predicate: 'belongsTo',
519
+ value: 'Target',
520
+ confidence: 1.0,
521
+ assertedAt: new Date().toISOString(),
522
+ });
523
+ // Attempting to approve for a different companion must not affect it
524
+ db.approveClaim(claim.id, 'companion-intruder');
525
+ expect(db.getApprovedClaims('companion-target')).toHaveLength(0);
526
+ // Approving with the correct companionId succeeds
527
+ db.approveClaim(claim.id, 'companion-target');
528
+ expect(db.getApprovedClaims('companion-target')).toHaveLength(1);
529
+ });
530
+ it('resets memory for the specified companion only', () => {
531
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
532
+ const c1 = db.proposeClaim({
533
+ id: crypto.randomUUID(),
534
+ companionId: 'comp-1',
535
+ subject: 'Fact1',
536
+ predicate: 'is',
537
+ value: 'One',
538
+ confidence: 1.0,
539
+ });
540
+ const c2 = db.proposeClaim({
541
+ id: crypto.randomUUID(),
542
+ companionId: 'comp-2',
543
+ subject: 'Fact2',
544
+ predicate: 'is',
545
+ value: 'Two',
546
+ confidence: 1.0,
547
+ });
548
+ db.approveClaim(c1.id, 'comp-1');
549
+ db.approveClaim(c2.id, 'comp-2');
550
+ db.recordEvent({
551
+ id: crypto.randomUUID(),
552
+ companionId: 'comp-1',
553
+ sourceType: 'chat_turn',
554
+ occurredAt: new Date().toISOString(),
555
+ payload: { text: 'Hello' },
556
+ });
557
+ db.resetMemory('comp-1');
558
+ expect(db.getApprovedClaims('comp-1')).toHaveLength(0);
559
+ expect(db.getRecentEvents('comp-1')).toHaveLength(0);
560
+ expect(db.getApprovedClaims('comp-2')).toHaveLength(1);
561
+ });
468
562
  });
469
563
  // ==========================================
470
564
  // Cross-Domain & Persistence Tests
@@ -576,5 +670,194 @@ describe('SiduriDatabase', () => {
576
670
  expect(db.searchClaims(cId, 'anything')).toHaveLength(0);
577
671
  expect(db.getApprovedClaims(cId)).toHaveLength(0);
578
672
  });
673
+ it('enforces directive state transitions (pending -> active -> disabled/rejected/revoked)', () => {
674
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
675
+ const cId = 'directive-state-test';
676
+ const dId = 'dir-lifecycle-1';
677
+ // 1. Commit directive in PENDING state
678
+ db.commitDirective({
679
+ id: dId,
680
+ companionId: cId,
681
+ priority: 60,
682
+ directive: 'Always verify claims',
683
+ status: 'PENDING',
684
+ category: 'behavioral',
685
+ });
686
+ // Pending directives must not be returned by getActiveDirectives
687
+ expect(db.getActiveDirectives(cId)).toHaveLength(0);
688
+ // 2. Approve directive
689
+ db.approveDirective(dId);
690
+ const active = db.getActiveDirectives(cId);
691
+ expect(active).toHaveLength(1);
692
+ expect(active[0].id).toBe(dId);
693
+ expect(active[0].status).toBe('ACTIVE');
694
+ // 3. Revoke directive
695
+ db.revokeDirective(dId);
696
+ expect(db.getActiveDirectives(cId)).toHaveLength(0);
697
+ // 4. Reject directive
698
+ const d2Id = 'dir-lifecycle-2';
699
+ db.commitDirective({
700
+ id: d2Id,
701
+ companionId: cId,
702
+ priority: 50,
703
+ directive: 'Unsafe rule',
704
+ status: 'PENDING',
705
+ category: 'behavioral',
706
+ });
707
+ db.rejectDirective(d2Id);
708
+ expect(db.getActiveDirectives(cId)).toHaveLength(0);
709
+ });
710
+ it('rejects invalid directive state transitions (throws when approving non-PENDING directive)', () => {
711
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
712
+ const cId = 'directive-invalid-transition-test';
713
+ // 1. Commit directive in REJECTED state
714
+ db.commitDirective({
715
+ id: 'dir-rejected-1',
716
+ companionId: cId,
717
+ priority: 50,
718
+ directive: 'Rejected directive',
719
+ status: 'REJECTED',
720
+ category: 'behavioral',
721
+ });
722
+ expect(() => db.approveDirective('dir-rejected-1')).toThrow(/invalid transition from status 'REJECTED' to 'ACTIVE'/);
723
+ // 2. Commit directive in ACTIVE state
724
+ db.commitDirective({
725
+ id: 'dir-active-1',
726
+ companionId: cId,
727
+ priority: 50,
728
+ directive: 'Already active directive',
729
+ status: 'ACTIVE',
730
+ category: 'behavioral',
731
+ });
732
+ expect(() => db.approveDirective('dir-active-1')).toThrow(/invalid transition from status 'ACTIVE' to 'ACTIVE'/);
733
+ // 3. Rejecting an already ACTIVE directive throws
734
+ expect(() => db.rejectDirective('dir-active-1')).toThrow(/invalid transition from status 'ACTIVE' to 'REJECTED'/);
735
+ });
736
+ it('automatically marks prior directive as SUPERSEDED when approving superseding directive', () => {
737
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
738
+ const cId = 'directive-supersede-test';
739
+ // 1. Initial directive active
740
+ db.commitDirective({
741
+ id: 'dir-original-1',
742
+ companionId: cId,
743
+ priority: 50,
744
+ directive: 'Original rule',
745
+ status: 'ACTIVE',
746
+ category: 'behavioral',
747
+ });
748
+ expect(db.getActiveDirectives(cId)).toHaveLength(1);
749
+ // 2. Propose a superseding directive
750
+ db.commitDirective({
751
+ id: 'dir-replacement-1',
752
+ companionId: cId,
753
+ priority: 55,
754
+ directive: 'Updated replacement rule',
755
+ status: 'PENDING',
756
+ category: 'behavioral',
757
+ supersedesId: 'dir-original-1',
758
+ });
759
+ // Original is still active, replacement is pending
760
+ expect(db.getActiveDirectives(cId)).toHaveLength(1);
761
+ expect(db.getActiveDirectives(cId)[0].id).toBe('dir-original-1');
762
+ // 3. Approve replacement directive
763
+ db.approveDirective('dir-replacement-1', cId);
764
+ const active = db.getActiveDirectives(cId);
765
+ expect(active).toHaveLength(1);
766
+ expect(active[0].id).toBe('dir-replacement-1');
767
+ const original = db.getDirective('dir-original-1', cId);
768
+ expect(original?.status).toBe('SUPERSEDED');
769
+ });
770
+ it('enforces companion isolation on directive approval', () => {
771
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
772
+ const cIdA = 'companion-alpha';
773
+ const cIdB = 'companion-beta';
774
+ db.commitDirective({
775
+ id: 'dir-beta-1',
776
+ companionId: cIdB,
777
+ priority: 50,
778
+ directive: 'Beta private rule',
779
+ status: 'PENDING',
780
+ category: 'behavioral',
781
+ });
782
+ // Alpha attempts to approve Beta's directive scoped to Alpha
783
+ db.approveDirective('dir-beta-1', cIdA);
784
+ // Beta's directive must remain PENDING and unapproved
785
+ const betaDirective = db.getDirective('dir-beta-1', cIdB);
786
+ expect(betaDirective?.status).toBe('PENDING');
787
+ expect(db.getActiveDirectives(cIdB)).toHaveLength(0);
788
+ // Beta approves its own directive successfully
789
+ db.approveDirective('dir-beta-1', cIdB);
790
+ expect(db.getActiveDirectives(cIdB)).toHaveLength(1);
791
+ });
792
+ it('enforces claim state transitions (pending -> approved -> revoked/expired/session_only)', () => {
793
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
794
+ const cId = 'claim-state-test';
795
+ const claim = db.proposeClaim({
796
+ id: 'claim-1',
797
+ companionId: cId,
798
+ subject: 'user',
799
+ predicate: 'likes',
800
+ value: 'matcha',
801
+ });
802
+ expect(claim.status).toBe('PENDING');
803
+ expect(db.getApprovedClaims(cId)).toHaveLength(0);
804
+ // Approve
805
+ db.approveClaim('claim-1');
806
+ expect(db.getApprovedClaims(cId)).toHaveLength(1);
807
+ // Revoke
808
+ db.revokeClaim('claim-1');
809
+ expect(db.getApprovedClaims(cId)).toHaveLength(0);
810
+ // Session only
811
+ const claim2 = db.proposeClaim({
812
+ id: 'claim-2',
813
+ companionId: cId,
814
+ subject: 'session',
815
+ predicate: 'topic',
816
+ value: 'investigation',
817
+ });
818
+ db.markClaimSessionOnly('claim-2');
819
+ expect(db.getApprovedClaims(cId)).toHaveLength(0);
820
+ // Expire
821
+ db.expireClaim('claim-2');
822
+ expect(db.getApprovedClaims(cId)).toHaveLength(0);
823
+ });
824
+ it('strictly rejects illegal claim state transitions in approveClaim', () => {
825
+ db = new siduri_db_1.SiduriDatabase({ dbPath });
826
+ const cId = 'claim-transition-test';
827
+ // 1. Propose and reject claim
828
+ const rejectedClaim = db.proposeClaim({
829
+ id: 'claim-rejected',
830
+ companionId: cId,
831
+ subject: 'fact',
832
+ predicate: 'is',
833
+ value: 'false',
834
+ });
835
+ db.rejectClaim(rejectedClaim.id);
836
+ // Attempting to approve a REJECTED claim must throw
837
+ expect(() => db.approveClaim(rejectedClaim.id)).toThrow(/invalid transition from status 'REJECTED' to 'APPROVED'/);
838
+ // 2. Propose and approve claim, then revoke
839
+ const revokedClaim = db.proposeClaim({
840
+ id: 'claim-revoked',
841
+ companionId: cId,
842
+ subject: 'fact',
843
+ predicate: 'is',
844
+ value: 'outdated',
845
+ });
846
+ db.approveClaim(revokedClaim.id);
847
+ db.revokeClaim(revokedClaim.id);
848
+ // Attempting to approve a REVOKED claim must throw
849
+ expect(() => db.approveClaim(revokedClaim.id)).toThrow(/invalid transition from status 'REVOKED' to 'APPROVED'/);
850
+ // 3. Propose and expire claim
851
+ const expiredClaim = db.proposeClaim({
852
+ id: 'claim-expired',
853
+ companionId: cId,
854
+ subject: 'fact',
855
+ predicate: 'is',
856
+ value: 'temporary',
857
+ });
858
+ db.expireClaim(expiredClaim.id);
859
+ // Attempting to approve an EXPIRED claim must throw
860
+ expect(() => db.approveClaim(expiredClaim.id)).toThrow(/invalid transition from status 'EXPIRED' to 'APPROVED'/);
861
+ });
579
862
  });
580
863
  });
@@ -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): 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;
@@ -36,6 +36,7 @@ class SqliteActionStore {
36
36
  execution_id TEXT PRIMARY KEY,
37
37
  approver_actor_id TEXT NOT NULL,
38
38
  reason TEXT,
39
+ approver_role TEXT,
39
40
  approved_at TEXT NOT NULL
40
41
  );
41
42
 
@@ -62,6 +63,12 @@ class SqliteActionStore {
62
63
  timestamp TEXT NOT NULL
63
64
  );
64
65
  `);
66
+ try {
67
+ this.db.exec('ALTER TABLE action_approvals ADD COLUMN approver_role TEXT;');
68
+ }
69
+ catch {
70
+ // Column already exists or table freshly created
71
+ }
65
72
  }
66
73
  initLastAuditHash() {
67
74
  const row = this.db.prepare('SELECT event_hash FROM action_audit_log ORDER BY id DESC LIMIT 1').get();
@@ -127,22 +134,37 @@ class SqliteActionStore {
127
134
  updatedAt: row.updated_at,
128
135
  };
129
136
  }
130
- async saveApproval(executionId, approverActorId, reason) {
137
+ async saveApproval(executionId, approverActorId, reason, approverRole) {
131
138
  const stmt = this.db.prepare(`
132
- INSERT INTO action_approvals (execution_id, approver_actor_id, reason, approved_at)
133
- VALUES (?, ?, ?, ?)
139
+ INSERT INTO action_approvals (execution_id, approver_actor_id, reason, approver_role, approved_at)
140
+ VALUES (?, ?, ?, ?, ?)
134
141
  ON CONFLICT(execution_id) DO UPDATE SET
135
142
  approver_actor_id = excluded.approver_actor_id,
136
143
  reason = excluded.reason,
144
+ approver_role = excluded.approver_role,
137
145
  approved_at = excluded.approved_at
138
146
  `);
139
- stmt.run(executionId, approverActorId, reason ?? null, new Date().toISOString());
147
+ stmt.run(executionId, approverActorId, reason ?? null, approverRole ?? null, new Date().toISOString());
140
148
  }
141
149
  async isActionApproved(executionId) {
142
150
  const stmt = this.db.prepare('SELECT 1 FROM action_approvals WHERE execution_id = ?');
143
151
  const row = stmt.get(executionId);
144
152
  return !!row;
145
153
  }
154
+ async getApproval(executionId) {
155
+ const stmt = this.db.prepare('SELECT * FROM action_approvals WHERE execution_id = ?');
156
+ const row = stmt.get(executionId);
157
+ if (!row) {
158
+ return undefined;
159
+ }
160
+ return {
161
+ executionId: row.execution_id,
162
+ approverActorId: row.approver_actor_id,
163
+ reason: row.reason ?? undefined,
164
+ approverRole: row.approver_role ?? undefined,
165
+ approvedAt: row.approved_at,
166
+ };
167
+ }
146
168
  async appendAudit(event) {
147
169
  const prevHash = this.lastAuditHash;
148
170
  const eventPayload = {
@@ -164,6 +186,7 @@ class SqliteActionStore {
164
186
  decisionCode: event.decision.decisionCode,
165
187
  } : null,
166
188
  parametersHash: event.parametersHash || null,
189
+ resultHash: event.resultHash || null,
167
190
  error: event.error || null,
168
191
  timestamp: event.timestamp,
169
192
  };
@@ -152,6 +152,7 @@ describe('SqliteActionStore Implementation & Durability', () => {
152
152
  lifecycle: event1.lifecycle,
153
153
  decision: null,
154
154
  parametersHash: null,
155
+ resultHash: null,
155
156
  error: null,
156
157
  timestamp: event1.timestamp,
157
158
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siduri-x/core",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "Core runtime types, evidence protocol, action dispatcher, capability validation, and SiduriRuntime protocol",
5
5
  "license": "Apache-2.0",
6
6
  "repository": {
@@ -17,7 +17,7 @@
17
17
  "LICENSE"
18
18
  ],
19
19
  "engines": {
20
- "node": ">=20"
20
+ "node": ">=22.16.0"
21
21
  },
22
22
  "main": "dist/index.js",
23
23
  "types": "dist/index.d.ts",
@@ -29,11 +29,11 @@
29
29
  }
30
30
  },
31
31
  "devDependencies": {
32
- "@types/jest": "^29.5.14",
33
- "@types/node": "^26.2.0",
34
- "jest": "^29.7.0",
32
+ "@types/jest": "^30.0.0",
33
+ "@types/node": "^26.5.1",
34
+ "jest": "^30.5.1",
35
35
  "ts-jest": "^29.4.12",
36
- "typescript": "^5.3.3"
36
+ "typescript": "^5.9.3"
37
37
  },
38
38
  "scripts": {
39
39
  "build": "tsc",