legion-data 1.1.5 → 1.3.7

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.
Files changed (91) hide show
  1. checksums.yaml +4 -4
  2. data/.github/workflows/ci.yml +16 -0
  3. data/.gitignore +10 -2
  4. data/.rubocop.yml +41 -18
  5. data/CHANGELOG.md +82 -15
  6. data/CLAUDE.md +199 -0
  7. data/Gemfile +11 -1
  8. data/LICENSE +201 -0
  9. data/README.md +163 -19
  10. data/exe/legionio_migrate +0 -0
  11. data/legion-data.gemspec +22 -35
  12. data/lib/legion/data/connection.rb +39 -25
  13. data/lib/legion/data/encryption/cipher.rb +49 -0
  14. data/lib/legion/data/encryption/key_provider.rb +45 -0
  15. data/lib/legion/data/encryption/sequel_plugin.rb +54 -0
  16. data/lib/legion/data/event_store/projection.rb +56 -0
  17. data/lib/legion/data/event_store.rb +112 -0
  18. data/lib/legion/data/local.rb +77 -0
  19. data/lib/legion/data/migration.rb +5 -3
  20. data/lib/legion/data/migrations/001_add_schema_columns.rb +9 -3
  21. data/lib/legion/data/migrations/002_add_nodes.rb +18 -0
  22. data/lib/legion/data/migrations/003_add_settings.rb +18 -0
  23. data/lib/legion/data/migrations/004_add_extensions.rb +23 -0
  24. data/lib/legion/data/migrations/005_add_runners.rb +21 -0
  25. data/lib/legion/data/migrations/006_add_functions.rb +21 -0
  26. data/lib/legion/data/migrations/{015_add_default_extensions.rb → 007_add_default_extensions.rb} +3 -0
  27. data/lib/legion/data/migrations/008_add_tasks.rb +23 -0
  28. data/lib/legion/data/migrations/009_add_digital_workers.rb +45 -0
  29. data/lib/legion/data/migrations/010_add_value_metrics.rb +19 -0
  30. data/lib/legion/data/migrations/011_add_extensions_registry.rb +30 -0
  31. data/lib/legion/data/migrations/012_add_apollo_tables.rb +66 -0
  32. data/lib/legion/data/migrations/013_add_relationships.rb +21 -0
  33. data/lib/legion/data/migrations/014_add_relationship_columns.rb +27 -0
  34. data/lib/legion/data/migrations/015_add_rbac_tables.rb +49 -0
  35. data/lib/legion/data/migrations/016_add_worker_health.rb +33 -0
  36. data/lib/legion/data/migrations/017_add_audit_log.rb +30 -0
  37. data/lib/legion/data/migrations/018_add_governance_events.rb +21 -0
  38. data/lib/legion/data/migrations/019_add_audit_hash_chain.rb +29 -0
  39. data/lib/legion/data/migrations/020_add_webhooks.rb +37 -0
  40. data/lib/legion/data/model.rb +5 -2
  41. data/lib/legion/data/models/apollo_access_log.rb +13 -0
  42. data/lib/legion/data/models/apollo_entry.rb +18 -0
  43. data/lib/legion/data/models/apollo_expertise.rb +12 -0
  44. data/lib/legion/data/models/apollo_relation.rb +14 -0
  45. data/lib/legion/data/models/audit_log.rb +34 -0
  46. data/lib/legion/data/models/digital_worker.rb +44 -0
  47. data/lib/legion/data/models/extension.rb +0 -0
  48. data/lib/legion/data/models/function.rb +0 -2
  49. data/lib/legion/data/models/node.rb +17 -3
  50. data/lib/legion/data/models/rbac_cross_team_grant.rb +33 -0
  51. data/lib/legion/data/models/rbac_role_assignment.rb +29 -0
  52. data/lib/legion/data/models/rbac_runner_grant.rb +21 -0
  53. data/lib/legion/data/models/relationship.rb +3 -6
  54. data/lib/legion/data/models/runner.rb +0 -0
  55. data/lib/legion/data/models/setting.rb +0 -0
  56. data/lib/legion/data/models/task.rb +0 -0
  57. data/lib/legion/data/models/task_log.rb +0 -0
  58. data/lib/legion/data/settings.rb +37 -8
  59. data/lib/legion/data/version.rb +3 -1
  60. data/lib/legion/data.rb +31 -13
  61. metadata +64 -139
  62. data/.circleci/config.yml +0 -174
  63. data/.rspec +0 -1
  64. data/Gemfile.lock +0 -85
  65. data/Rakefile +0 -55
  66. data/bin/console +0 -14
  67. data/bin/setup +0 -8
  68. data/bitbucket-pipelines.yml +0 -26
  69. data/lib/legion/data/migrations/002_add_users.rb +0 -17
  70. data/lib/legion/data/migrations/003_add_groups.rb +0 -16
  71. data/lib/legion/data/migrations/004_add_chains.rb +0 -25
  72. data/lib/legion/data/migrations/005_add_envs.rb +0 -24
  73. data/lib/legion/data/migrations/006_add_dcs.rb +0 -24
  74. data/lib/legion/data/migrations/007_add_nodes.rb +0 -26
  75. data/lib/legion/data/migrations/008_add_settings.rb +0 -18
  76. data/lib/legion/data/migrations/009_add_extensions.rb +0 -25
  77. data/lib/legion/data/migrations/010_add_runners.rb +0 -21
  78. data/lib/legion/data/migrations/011_add_functions.rb +0 -29
  79. data/lib/legion/data/migrations/012_add_tasks.rb +0 -28
  80. data/lib/legion/data/migrations/013_add_task_logs.rb +0 -23
  81. data/lib/legion/data/migrations/014_add_relationships.rb +0 -27
  82. data/lib/legion/data/migrations/016_change_task_args.rb +0 -7
  83. data/lib/legion/data/migrations/017_add_payload_task.rb +0 -7
  84. data/lib/legion/data/migrations/018_add_migration_column.rb +0 -7
  85. data/lib/legion/data/migrations/019_add_debug_to_relationships.rb +0 -7
  86. data/lib/legion/data/migrations/020_add_delay_debug_to_tasks.rb +0 -8
  87. data/lib/legion/data/models/chain.rb +0 -11
  88. data/lib/legion/data/models/datacenter.rb +0 -11
  89. data/lib/legion/data/models/environment.rb +0 -11
  90. data/lib/legion/data/models/group.rb +0 -10
  91. data/lib/legion/data/models/user.rb +0 -10
data/README.md CHANGED
@@ -1,37 +1,181 @@
1
- # Legion::Data
1
+ # legion-data
2
2
 
3
- Legion::Data is used by the framework to connect to a database. All database changes should be
4
- added as a migration with proper up/downs.
3
+ Persistent database storage for the [LegionIO](https://github.com/LegionIO/LegionIO) framework. Provides database connectivity via Sequel ORM, automatic schema migrations, and data models for extensions, functions, runners, nodes, tasks, settings, digital workers, task relationships, and Apollo shared knowledge tables.
4
+
5
+ Version: 1.3.0
6
+
7
+ ## Supported Databases
8
+
9
+ | Database | Adapter | Gem | Default |
10
+ |----------|---------|-----|---------|
11
+ | SQLite | `sqlite` | `sqlite3` (included) | Yes |
12
+ | MySQL | `mysql2` | `mysql2` | No |
13
+ | PostgreSQL | `postgres` | `pg` | No |
14
+
15
+ SQLite is the default adapter and requires no external database server. For MySQL or PostgreSQL, install the corresponding gem and set the adapter in your configuration.
5
16
 
6
17
  ## Installation
7
18
 
8
- Add this line to your application's Gemfile:
19
+ ```bash
20
+ gem install legion-data
21
+ ```
22
+
23
+ Or add to your Gemfile:
9
24
 
10
25
  ```ruby
11
26
  gem 'legion-data'
12
- ```
13
27
 
14
- And then execute:
28
+ # Add one of these for production databases:
29
+ # gem 'mysql2', '>= 0.5.5'
30
+ # gem 'pg', '>= 1.5'
31
+ ```
15
32
 
16
- $ bundle
33
+ ## Data Models
17
34
 
18
- Or install it yourself as:
35
+ | Model | Table | Description |
36
+ |-------|-------|-------------|
37
+ | `Extension` | `extensions` | Installed LEX extensions |
38
+ | `Function` | `functions` | Available functions per extension |
39
+ | `Runner` | `runners` | Runner definitions (extension + function bindings) |
40
+ | `Node` | `nodes` | Cluster node registry |
41
+ | `Task` | `tasks` | Task instances |
42
+ | `TaskLog` | `task_logs` | Task execution logs |
43
+ | `Setting` | `settings` | Persistent settings store |
44
+ | `DigitalWorker` | `digital_workers` | Digital worker registry (AI-as-labor platform) |
45
+ | `Relationship` | `relationships` | Task trigger/action relationships between functions |
46
+ | `ApolloEntry` | `apollo_entries` | Apollo shared knowledge entries (PostgreSQL only) |
47
+ | `ApolloRelation` | `apollo_relations` | Relations between Apollo knowledge entries (PostgreSQL only) |
48
+ | `ApolloExpertise` | `apollo_expertise` | Per-agent domain expertise tracking (PostgreSQL only) |
49
+ | `ApolloAccessLog` | `apollo_access_log` | Apollo entry access audit log (PostgreSQL only) |
19
50
 
20
- $ gem install legion-data
51
+ Apollo models require PostgreSQL with the `pgvector` extension. They are skipped silently on SQLite and MySQL.
21
52
 
22
53
  ## Usage
23
54
 
24
- You can create a new connection to the database with the following example
25
- ``` Legion::Data::Connection.new.database.connection ```
26
- Keep in mind that if you need access to the database as part of a LEX, you should instead add it as a
27
- requirement inside your definitions with the following
55
+ ```ruby
56
+ require 'legion/data'
57
+
58
+ # Standard setup (shared DB + local SQLite)
59
+ Legion::Data.setup
60
+ Legion::Data.connection # => Sequel::Database (shared)
61
+ Legion::Data.local.connection # => Sequel::SQLite::Database (local cognitive state)
62
+ Legion::Data::Model::Extension.all # => Sequel::Dataset
63
+ ```
64
+
65
+ ### Local Database
66
+
67
+ v1.3.0 introduces `Legion::Data::Local`, a parallel SQLite database always stored locally on the node. It is used for agentic cognitive state persistence (memory traces, trust scores, dream journals, etc.) and is independent of the shared database.
68
+
69
+ ```ruby
70
+ # Local DB is set up automatically during Legion::Data.setup
71
+ # Extensions register their own migration directories
72
+ Legion::Data::Local.register_migrations(name: :memory, path: '/path/to/migrations')
73
+
74
+ # Create a model bound to the local connection
75
+ MyModel = Legion::Data::Local.model(:my_table)
76
+
77
+ # Check status
78
+ Legion::Data::Local.connected? # => true
79
+ Legion::Data::Local.db_path # => "legionio_local.db"
80
+ ```
81
+
82
+ The local database file (`legionio_local.db` by default) can be deleted for cryptographic erasure — no residual data. This is used by `lex-privatecore`.
83
+
84
+ ## Configuration
85
+
86
+ ### SQLite (default)
87
+
88
+ ```json
89
+ {
90
+ "data": {
91
+ "adapter": "sqlite",
92
+ "creds": {
93
+ "database": "legionio.db"
94
+ }
95
+ }
96
+ }
97
+ ```
98
+
99
+ ### MySQL
100
+
101
+ ```json
102
+ {
103
+ "data": {
104
+ "adapter": "mysql2",
105
+ "creds": {
106
+ "username": "legion",
107
+ "password": "legion",
108
+ "database": "legionio",
109
+ "host": "127.0.0.1",
110
+ "port": 3306
111
+ }
112
+ }
113
+ }
28
114
  ```
29
- def requirements
30
- %w[legion-transport legion-data]
31
- end
115
+
116
+ ### PostgreSQL
117
+
118
+ ```json
119
+ {
120
+ "data": {
121
+ "adapter": "postgres",
122
+ "creds": {
123
+ "user": "legion",
124
+ "password": "legion",
125
+ "database": "legionio",
126
+ "host": "127.0.0.1",
127
+ "port": 5432
128
+ }
129
+ }
130
+ }
32
131
  ```
33
- and the framework will take care of the rest.
34
132
 
35
- ## Contributing
133
+ PostgreSQL with `pgvector` is required for Apollo models. Install the extension in your database before running migrations:
134
+
135
+ ```sql
136
+ CREATE EXTENSION IF NOT EXISTS vector;
137
+ CREATE EXTENSION IF NOT EXISTS "uuid-ossp";
138
+ ```
139
+
140
+ ### Local Database
141
+
142
+ ```json
143
+ {
144
+ "data": {
145
+ "local": {
146
+ "enabled": true,
147
+ "database": "legionio_local.db",
148
+ "migrations": {
149
+ "auto_migrate": true
150
+ }
151
+ }
152
+ }
153
+ }
154
+ ```
155
+
156
+ Set `enabled: false` to disable local SQLite entirely.
157
+
158
+ ### Dev Mode Fallback
159
+
160
+ When `dev_mode: true` and a network database (MySQL/PostgreSQL) is unreachable, the shared connection falls back to SQLite automatically instead of raising.
161
+
162
+ ```json
163
+ {
164
+ "data": {
165
+ "dev_mode": true,
166
+ "dev_fallback": true
167
+ }
168
+ }
169
+ ```
170
+
171
+ ### HashiCorp Vault Integration
172
+
173
+ When Vault is connected and a `database/creds/legion` secret path exists, credentials are fetched dynamically from Vault at connection time, overriding any static `creds` configuration.
174
+
175
+ ## Requirements
176
+
177
+ - Ruby >= 3.4
178
+
179
+ ## License
36
180
 
37
- Bug reports and pull requests are welcome on GitHub at https://bitbucket.org/legion-io/legion-data.
181
+ Apache-2.0
File without changes
data/legion-data.gemspec CHANGED
@@ -1,46 +1,33 @@
1
- lib = File.expand_path('lib', __dir__)
2
- $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
- require 'legion/data/version'
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'lib/legion/data/version'
4
4
 
5
5
  Gem::Specification.new do |spec|
6
- spec.name = (RUBY_ENGINE == 'jruby' ? 'legion-data-java' : 'legion-data')
6
+ spec.name = 'legion-data'
7
7
  spec.version = Legion::Data::VERSION
8
8
  spec.authors = ['Esity']
9
9
  spec.email = ['matthewdiverson@gmail.com']
10
10
 
11
- spec.summary = 'Used by Legion to connect to the database'
12
- spec.description = 'The Legion connect gem'
13
- spec.homepage = 'https://bitbucket.org/legion-io/legion-data'
14
- spec.required_ruby_version = Gem::Requirement.new('>= 2.5.0')
15
-
16
- spec.metadata['bug_tracker_uri'] = 'https://bitbucket.org/legion-io/legion-data/issues?status=new&status=open'
17
- spec.metadata['changelog_uri'] = 'https://bitbucket.org/legion-io/legion-data/src/CHANGELOG.md'
18
- spec.metadata['documentation_uri'] = 'https://bitbucket.org/legion-io/legion-data'
19
- spec.metadata['homepage_uri'] = 'https://bitbucket.org/legion-io/legion-data'
20
- spec.metadata['source_code_uri'] = 'https://bitbucket.org/legion-io/legion-data'
21
- spec.metadata['wiki_uri'] = 'https://bitbucket.org/legion-io/legion-data/wiki/Home'
22
-
23
- spec.files = `git ls-files -z`.split("\x0").reject do |f|
24
- f.match(%r{^(test|spec|features)/})
25
- end
26
- spec.bindir = 'bin'
27
- spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
11
+ spec.summary = 'Manages the connects to the backend database'
12
+ spec.description = 'A LegionIO gem to connect to a persistent data store'
13
+ spec.homepage = 'https://github.com/LegionIO/legion-data'
14
+ spec.license = 'Apache-2.0'
15
+ spec.required_ruby_version = '>= 3.4'
28
16
  spec.require_paths = ['lib']
29
-
30
- spec.add_development_dependency 'bundler'
31
- spec.add_development_dependency 'codecov'
32
- spec.add_development_dependency 'rake'
33
- spec.add_development_dependency 'rspec'
34
- spec.add_development_dependency 'rspec_junit_formatter'
35
- spec.add_development_dependency 'rubocop'
17
+ spec.files = `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
+ spec.extra_rdoc_files = %w[README.md LICENSE CHANGELOG.md]
19
+ spec.metadata = {
20
+ 'bug_tracker_uri' => 'https://github.com/LegionIO/legion-data/issues',
21
+ 'changelog_uri' => 'https://github.com/LegionIO/legion-data/blob/main/CHANGELOG.md',
22
+ 'documentation_uri' => 'https://github.com/LegionIO/legion-data',
23
+ 'homepage_uri' => 'https://github.com/LegionIO/LegionIO',
24
+ 'source_code_uri' => 'https://github.com/LegionIO/legion-data',
25
+ 'wiki_uri' => 'https://github.com/LegionIO/legion-data/wiki',
26
+ 'rubygems_mfa_required' => 'true'
27
+ }
36
28
 
37
29
  spec.add_dependency 'legion-logging'
38
30
  spec.add_dependency 'legion-settings'
39
-
40
- if RUBY_ENGINE == 'jruby'
41
- spec.add_dependency 'jdbc-mysql'
42
- else
43
- spec.add_dependency 'mysql2'
44
- end
45
- spec.add_dependency 'sequel'
31
+ spec.add_dependency 'sequel', '>= 5.70'
32
+ spec.add_dependency 'sqlite3', '>= 2.0'
46
33
  end
@@ -1,27 +1,39 @@
1
+ # frozen_string_literal: true
2
+
1
3
  require 'sequel'
2
4
 
3
5
  module Legion
4
6
  module Data
5
7
  module Connection
8
+ ADAPTERS = %i[sqlite mysql2 postgres].freeze
9
+
6
10
  class << self
7
11
  attr_accessor :sequel
8
12
 
9
13
  def adapter
10
- @adapter ||= RUBY_ENGINE == 'jruby' ? :jdbc : :mysql2
14
+ @adapter ||= Legion::Settings[:data][:adapter]&.to_sym || :sqlite
11
15
  end
12
16
 
13
17
  def setup
14
- @sequel = if adapter == :mysql2
15
- ::Sequel.connect(adapter: adapter, **creds_builder)
18
+ @sequel = if adapter == :sqlite
19
+ ::Sequel.sqlite(sqlite_path)
16
20
  else
17
- ::Sequel.connect("jdbc:mysql://#{creds_builder[:host]}:#{creds_builder[:port]}/#{creds_builder[:database]}?user=#{creds_builder[:username]}&password=#{creds_builder[:password]}&serverTimezone=UTC") # rubocop:disable Layout/LineLength
21
+ begin
22
+ ::Sequel.connect(adapter: adapter, **creds_builder)
23
+ rescue StandardError => e
24
+ raise unless dev_fallback?
25
+
26
+ if defined?(Legion::Logging)
27
+ Legion::Logging.warn(
28
+ "Shared DB unreachable (#{e.message}), dev_mode fallback to SQLite"
29
+ )
30
+ end
31
+ @adapter = :sqlite
32
+ ::Sequel.sqlite(sqlite_path)
33
+ end
18
34
  end
19
35
  Legion::Settings[:data][:connected] = true
20
- return if Legion::Settings[:data][:connection].nil? || Legion::Settings[:data][:connection][:log].nil?
21
-
22
- @sequel.logger = Legion::Logging
23
- @sequel.sql_log_level = Legion::Settings[:data][:connection][:sql_log_level]
24
- @sequel.log_warn_duration = Legion::Settings[:data][:connection][:log_warn_duration]
36
+ configure_logging
25
37
  end
26
38
 
27
39
  def shutdown
@@ -30,15 +42,9 @@ module Legion
30
42
  end
31
43
 
32
44
  def creds_builder(final_creds = {})
33
- final_creds.merge! Legion::Data::Settings.creds
45
+ final_creds.merge! Legion::Data::Settings.creds(adapter)
34
46
  final_creds.merge! Legion::Settings[:data][:creds] if Legion::Settings[:data][:creds].is_a? Hash
35
47
 
36
- if Legion::Settings[:data][:connection][:max_connections].is_a? Integer
37
- final_creds[:max_connections] = Legion::Settings[:data][:connection][:max_connections]
38
- end
39
-
40
- final_creds[:preconnect] = :concurrently if Legion::Settings[:data][:connection][:preconnect]
41
-
42
48
  return final_creds if Legion::Settings[:vault].nil?
43
49
 
44
50
  if Legion::Settings[:vault][:connected] && ::Vault.sys.mounts.key?(:database)
@@ -50,15 +56,23 @@ module Legion
50
56
  final_creds
51
57
  end
52
58
 
53
- def default_creds
54
- {
55
- host: '127.0.0.1',
56
- port: 3306,
57
- username: 'legion',
58
- password: 'legion',
59
- database: 'legion',
60
- max_connections: 4
61
- }
59
+ private
60
+
61
+ def dev_fallback?
62
+ data_settings = Legion::Settings[:data]
63
+ data_settings[:dev_mode] == true && data_settings[:dev_fallback] != false
64
+ end
65
+
66
+ def sqlite_path
67
+ Legion::Settings[:data][:creds][:database] || 'legionio.db'
68
+ end
69
+
70
+ def configure_logging
71
+ return if Legion::Settings[:data][:connection].nil? || Legion::Settings[:data][:connection][:log].nil?
72
+
73
+ @sequel.logger = Legion::Logging
74
+ @sequel.sql_log_level = Legion::Settings[:data][:connection][:sql_log_level]
75
+ @sequel.log_warn_duration = Legion::Settings[:data][:connection][:log_warn_duration]
62
76
  end
63
77
  end
64
78
  end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Legion
6
+ module Data
7
+ module Encryption
8
+ module Cipher
9
+ VERSION_BYTE = "\x01".b.freeze
10
+ IV_LENGTH = 12
11
+ TAG_LENGTH = 16
12
+
13
+ class << self
14
+ def encrypt(plaintext, key:, aad: '')
15
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').encrypt
16
+ iv = OpenSSL::Random.random_bytes(IV_LENGTH)
17
+ cipher.key = key
18
+ cipher.iv = iv
19
+ cipher.auth_data = aad
20
+
21
+ ciphertext = cipher.update(plaintext.to_s) + cipher.final
22
+ tag = cipher.auth_tag(TAG_LENGTH)
23
+
24
+ VERSION_BYTE + iv + ciphertext + tag
25
+ end
26
+
27
+ def decrypt(blob, key:, aad: '')
28
+ raise ArgumentError, 'data too short' if blob.bytesize < 1 + IV_LENGTH + TAG_LENGTH
29
+
30
+ version = blob.byteslice(0, 1)
31
+ raise ArgumentError, "unsupported version: #{version.unpack1('C')}" unless version == VERSION_BYTE
32
+
33
+ iv = blob.byteslice(1, IV_LENGTH)
34
+ tag = blob.byteslice(-TAG_LENGTH, TAG_LENGTH)
35
+ ciphertext = blob.byteslice(1 + IV_LENGTH, blob.bytesize - 1 - IV_LENGTH - TAG_LENGTH)
36
+
37
+ cipher = OpenSSL::Cipher.new('aes-256-gcm').decrypt
38
+ cipher.key = key
39
+ cipher.iv = iv
40
+ cipher.auth_tag = tag
41
+ cipher.auth_data = aad
42
+
43
+ cipher.update(ciphertext) + cipher.final
44
+ end
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'openssl'
4
+
5
+ module Legion
6
+ module Data
7
+ module Encryption
8
+ class KeyProvider
9
+ def initialize(mode: :auto)
10
+ @mode = mode
11
+ @key_cache = {}
12
+ end
13
+
14
+ def key_for(tenant_id: nil)
15
+ cache_key = tenant_id || '__default__'
16
+ @key_cache[cache_key] ||= derive_key(tenant_id)
17
+ end
18
+
19
+ def clear_cache!
20
+ @key_cache.clear
21
+ end
22
+
23
+ private
24
+
25
+ def derive_key(tenant_id)
26
+ if tenant_id && crypt_available?
27
+ Legion::Crypt::PartitionKeys.derive(tenant_id: tenant_id)
28
+ elsif crypt_available?
29
+ Legion::Crypt.default_encryption_key
30
+ else
31
+ local_key
32
+ end
33
+ end
34
+
35
+ def crypt_available?
36
+ defined?(Legion::Crypt::PartitionKeys)
37
+ end
38
+
39
+ def local_key
40
+ OpenSSL::Digest.digest('SHA256', 'legion-dev-encryption-key')
41
+ end
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,54 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'cipher'
4
+ require_relative 'key_provider'
5
+
6
+ module Legion
7
+ module Data
8
+ module Encryption
9
+ module SequelPlugin
10
+ module ClassMethods
11
+ def encrypted_columns
12
+ @encrypted_columns ||= {}
13
+ end
14
+
15
+ def encrypted_column(name, key_scope: :default)
16
+ col_scope = key_scope
17
+ encrypted_columns[name] = { key_scope: col_scope }
18
+
19
+ define_method(name) do
20
+ raw = super()
21
+ return nil if raw.nil?
22
+
23
+ provider = self.class.encryption_key_provider
24
+ tenant = col_scope == :tenant ? self[:tenant_id] : nil
25
+ key = provider.key_for(tenant_id: tenant)
26
+ aad = "#{self.class.table_name}:#{pk}:#{name}"
27
+ Legion::Data::Encryption::Cipher.decrypt(raw.b, key: key, aad: aad)
28
+ end
29
+
30
+ define_method(:"#{name}=") do |value|
31
+ if value.nil?
32
+ super(nil)
33
+ else
34
+ provider = self.class.encryption_key_provider
35
+ tenant = col_scope == :tenant ? self[:tenant_id] : nil
36
+ key = provider.key_for(tenant_id: tenant)
37
+ aad = "#{self.class.table_name}:#{pk || 0}:#{name}"
38
+ encrypted = Legion::Data::Encryption::Cipher.encrypt(value.to_s, key: key, aad: aad)
39
+ super(Sequel.blob(encrypted))
40
+ end
41
+ end
42
+ end
43
+
44
+ def encryption_key_provider
45
+ @encryption_key_provider ||= KeyProvider.new
46
+ end
47
+ end
48
+
49
+ module InstanceMethods
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Legion
4
+ module Data
5
+ module EventStore
6
+ class Projection
7
+ attr_reader :state
8
+
9
+ def initialize
10
+ @state = {}
11
+ end
12
+
13
+ def apply(_event)
14
+ raise NotImplementedError, "#{self.class} must implement #apply"
15
+ end
16
+
17
+ def self.build_from(stream, since: nil)
18
+ projection = new
19
+ events = EventStore.read_stream(stream, since: since)
20
+ events.each { |e| projection.apply(e) }
21
+ projection
22
+ end
23
+ end
24
+
25
+ class ConsentState < Projection
26
+ def apply(event)
27
+ scope = event.dig(:data, :scope)
28
+ return unless scope
29
+
30
+ case event[:type]
31
+ when 'consent.granted', 'consent.modified'
32
+ @state[scope] = event.dig(:data, :tier)
33
+ when 'consent.revoked'
34
+ @state.delete(scope)
35
+ end
36
+ end
37
+ end
38
+
39
+ class GovernanceTimeline < Projection
40
+ def initialize
41
+ super
42
+ @state = []
43
+ end
44
+
45
+ def apply(event)
46
+ @state << {
47
+ type: event[:type],
48
+ stream: event[:stream],
49
+ at: event[:created_at],
50
+ data: event[:data]
51
+ }
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'digest'
4
+
5
+ module Legion
6
+ module Data
7
+ module EventStore
8
+ GOVERNANCE_EVENT_TYPES = %w[
9
+ consent.granted consent.revoked consent.modified
10
+ extinction.triggered extinction.resolved
11
+ worker.registered worker.retired worker.transferred
12
+ scope.approved scope.violated scope.reconciled
13
+ audit.retention_applied audit.exported
14
+ ].freeze
15
+
16
+ class << self
17
+ def append(stream:, type:, data: {}, metadata: {})
18
+ return { error: 'db unavailable' } unless db_ready?
19
+
20
+ conn = Legion::Data.connection
21
+ conn.transaction do
22
+ last = conn[:governance_events]
23
+ .where(stream_id: stream)
24
+ .order(Sequel.desc(:sequence_number))
25
+ .first
26
+
27
+ seq = (last&.[](:sequence_number) || 0) + 1
28
+ prev_hash = last&.[](:event_hash) || ('0' * 64)
29
+
30
+ data_json = Legion::JSON.dump(data)
31
+ metadata_json = Legion::JSON.dump(metadata)
32
+ event_hash = compute_hash(stream, seq, type, data_json, prev_hash)
33
+
34
+ conn[:governance_events].insert(
35
+ stream_id: stream,
36
+ event_type: type,
37
+ sequence_number: seq,
38
+ data_json: data_json,
39
+ metadata_json: metadata_json,
40
+ event_hash: event_hash,
41
+ previous_hash: prev_hash,
42
+ created_at: Time.now
43
+ )
44
+
45
+ { stream: stream, sequence: seq, hash: event_hash }
46
+ end
47
+ end
48
+
49
+ def read_stream(stream, since: nil)
50
+ return [] unless db_ready?
51
+
52
+ ds = Legion::Data.connection[:governance_events].where(stream_id: stream)
53
+ ds = ds.where { created_at >= since } if since
54
+ ds.order(:sequence_number).all.map { |e| deserialize(e) }
55
+ end
56
+
57
+ def read_by_type(type, since: nil, limit: 100)
58
+ return [] unless db_ready?
59
+
60
+ ds = Legion::Data.connection[:governance_events].where(event_type: type)
61
+ ds = ds.where { created_at >= since } if since
62
+ ds.order(Sequel.desc(:created_at)).limit(limit).all.map { |e| deserialize(e) }
63
+ end
64
+
65
+ def verify_chain(stream)
66
+ return { valid: false, error: 'db unavailable' } unless db_ready?
67
+
68
+ events = Legion::Data.connection[:governance_events]
69
+ .where(stream_id: stream)
70
+ .order(:sequence_number)
71
+ .all
72
+
73
+ prev_hash = '0' * 64
74
+ events.each do |e|
75
+ expected = compute_hash(stream, e[:sequence_number], e[:event_type], e[:data_json], prev_hash)
76
+ return { valid: false, broken_at: e[:sequence_number] } unless e[:event_hash] == expected
77
+ return { valid: false, broken_at: e[:sequence_number] } unless e[:previous_hash] == prev_hash
78
+
79
+ prev_hash = e[:event_hash]
80
+ end
81
+
82
+ { valid: true, length: events.size }
83
+ end
84
+
85
+ private
86
+
87
+ def compute_hash(stream, seq, type, data_json, prev_hash)
88
+ Digest::SHA256.hexdigest("#{stream}:#{seq}:#{type}:#{data_json}:#{prev_hash}")
89
+ end
90
+
91
+ def deserialize(event)
92
+ {
93
+ id: event[:id],
94
+ stream: event[:stream_id],
95
+ type: event[:event_type],
96
+ sequence: event[:sequence_number],
97
+ data: Legion::JSON.load(event[:data_json] || '{}'),
98
+ metadata: Legion::JSON.load(event[:metadata_json] || '{}'),
99
+ hash: event[:event_hash],
100
+ created_at: event[:created_at]
101
+ }
102
+ end
103
+
104
+ def db_ready?
105
+ defined?(Legion::Data) && Legion::Data.connection&.table_exists?(:governance_events)
106
+ rescue StandardError
107
+ false
108
+ end
109
+ end
110
+ end
111
+ end
112
+ end