rootsign 0.1.1__py3-none-any.whl

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 (64) hide show
  1. rootsign/__init__.py +106 -0
  2. rootsign/_migrations/__init__.py +7 -0
  3. rootsign/_migrations/env.py +65 -0
  4. rootsign/_migrations/script.py.mako +25 -0
  5. rootsign/_migrations/versions/0001_initial_schema.py +387 -0
  6. rootsign/_migrations/versions/0002_approval_parent_id.py +56 -0
  7. rootsign/_migrations/versions/0003_action_timed_out.py +50 -0
  8. rootsign/_version.py +29 -0
  9. rootsign/cli.py +230 -0
  10. rootsign/config.py +32 -0
  11. rootsign/crud/__init__.py +28 -0
  12. rootsign/crud/action.py +201 -0
  13. rootsign/crud/agent.py +10 -0
  14. rootsign/crud/approval.py +298 -0
  15. rootsign/crud/base.py +74 -0
  16. rootsign/crud/decision.py +39 -0
  17. rootsign/crud/incident.py +10 -0
  18. rootsign/crud/policy.py +10 -0
  19. rootsign/crud/session.py +10 -0
  20. rootsign/database.py +32 -0
  21. rootsign/errors.py +173 -0
  22. rootsign/hashing.py +47 -0
  23. rootsign/ingest/__init__.py +19 -0
  24. rootsign/ingest/handler.py +351 -0
  25. rootsign/ingest/idempotency.py +66 -0
  26. rootsign/ingest/schemas.py +203 -0
  27. rootsign/models/__init__.py +19 -0
  28. rootsign/models/action.py +86 -0
  29. rootsign/models/agent.py +66 -0
  30. rootsign/models/approval.py +66 -0
  31. rootsign/models/decision.py +61 -0
  32. rootsign/models/incident.py +62 -0
  33. rootsign/models/policy.py +50 -0
  34. rootsign/models/session.py +63 -0
  35. rootsign/schemas/__init__.py +83 -0
  36. rootsign/schemas/action.py +57 -0
  37. rootsign/schemas/agent.py +66 -0
  38. rootsign/schemas/approval.py +42 -0
  39. rootsign/schemas/decision.py +34 -0
  40. rootsign/schemas/incident.py +62 -0
  41. rootsign/schemas/policy.py +48 -0
  42. rootsign/schemas/session.py +50 -0
  43. rootsign/sdk/__init__.py +40 -0
  44. rootsign/sdk/_async_bridge.py +50 -0
  45. rootsign/sdk/chain.py +151 -0
  46. rootsign/sdk/cli.py +300 -0
  47. rootsign/sdk/client.py +107 -0
  48. rootsign/sdk/config.py +78 -0
  49. rootsign/sdk/context.py +127 -0
  50. rootsign/sdk/decorator.py +612 -0
  51. rootsign/sdk/frameworks/__init__.py +6 -0
  52. rootsign/sdk/frameworks/crewai.py +154 -0
  53. rootsign/sdk/frameworks/langgraph.py +125 -0
  54. rootsign/sdk/hashing.py +42 -0
  55. rootsign/sdk/hitl.py +231 -0
  56. rootsign/sdk/redaction.py +228 -0
  57. rootsign/sdk/registration.py +73 -0
  58. rootsign/sdk/session.py +119 -0
  59. rootsign-0.1.1.dist-info/METADATA +313 -0
  60. rootsign-0.1.1.dist-info/RECORD +64 -0
  61. rootsign-0.1.1.dist-info/WHEEL +4 -0
  62. rootsign-0.1.1.dist-info/entry_points.txt +3 -0
  63. rootsign-0.1.1.dist-info/licenses/LICENSE +201 -0
  64. rootsign-0.1.1.dist-info/licenses/NOTICE +10 -0
rootsign/__init__.py ADDED
@@ -0,0 +1,106 @@
1
+ """RootSign — tamper-evident provenance logging for AI agents (Providex AI).
2
+
3
+ Top-level public surface — the only names users should import directly. The
4
+ internal layout (`rootsign.sdk.*`, `rootsign.ingest.*`, `rootsign.crud.*`)
5
+ remains accessible but is not part of the stable contract.
6
+ """
7
+
8
+ from rootsign.errors import (
9
+ EscalationDepthExceededError,
10
+ HiTLRejectedError,
11
+ HiTLTimeoutError,
12
+ RootSignError,
13
+ )
14
+ from rootsign.schemas.agent import (
15
+ AgentEnvironment,
16
+ AgentFramework,
17
+ AgentRiskTier,
18
+ )
19
+ from rootsign.sdk.chain import (
20
+ VerifyResult,
21
+ verify_session,
22
+ verify_session_local,
23
+ )
24
+ from rootsign.sdk.client import (
25
+ HttpIngestClient,
26
+ IngestClient,
27
+ LocalIngestClient,
28
+ get_ingest_client,
29
+ )
30
+ from rootsign.sdk.context import SessionContext
31
+ from rootsign.sdk.decorator import trace
32
+ from rootsign.sdk.frameworks.crewai import CrewAITracer
33
+ from rootsign.sdk.frameworks.langgraph import LangGraphTracer
34
+ from rootsign.sdk.redaction import (
35
+ FinancialPIIConfig,
36
+ HealthcarePIIConfig,
37
+ RedactionConfig,
38
+ StandardPIIConfig,
39
+ )
40
+ from rootsign.sdk.registration import register_agent
41
+ from rootsign.sdk.session import session
42
+
43
+ from rootsign._version import __version__ # noqa: E402, F401 (re-export)
44
+
45
+
46
+ def wrap_tools(
47
+ tools: list,
48
+ *,
49
+ ctx: SessionContext,
50
+ client: IngestClient,
51
+ redaction_config: RedactionConfig | None = None,
52
+ ) -> list:
53
+ """Convenience wrapper — instrument a list of LangGraph tools.
54
+
55
+ Drop-in replacement for the input list of `ToolNode([...])`. See
56
+ ADR-004 for the wrapping strategy.
57
+ """
58
+ return LangGraphTracer.wrap_tools(
59
+ tools, ctx=ctx, client=client, redaction_config=redaction_config
60
+ )
61
+
62
+
63
+ def wrap_crewai_tools(
64
+ tools: list,
65
+ *,
66
+ ctx: SessionContext,
67
+ client: IngestClient,
68
+ redaction_config: RedactionConfig | None = None,
69
+ ) -> list:
70
+ """Convenience wrapper — instrument a list of CrewAI tools.
71
+
72
+ Drop-in for `Agent(tools=[...])`. See ADR-005.
73
+ """
74
+ return CrewAITracer.wrap_tools(
75
+ tools, ctx=ctx, client=client, redaction_config=redaction_config
76
+ )
77
+
78
+
79
+ __all__ = [
80
+ "AgentEnvironment",
81
+ "AgentFramework",
82
+ "AgentRiskTier",
83
+ "CrewAITracer",
84
+ "EscalationDepthExceededError",
85
+ "FinancialPIIConfig",
86
+ "HealthcarePIIConfig",
87
+ "HiTLRejectedError",
88
+ "HiTLTimeoutError",
89
+ "HttpIngestClient",
90
+ "IngestClient",
91
+ "LangGraphTracer",
92
+ "LocalIngestClient",
93
+ "RedactionConfig",
94
+ "RootSignError",
95
+ "SessionContext",
96
+ "StandardPIIConfig",
97
+ "VerifyResult",
98
+ "get_ingest_client",
99
+ "register_agent",
100
+ "session",
101
+ "trace",
102
+ "verify_session",
103
+ "verify_session_local",
104
+ "wrap_crewai_tools",
105
+ "wrap_tools",
106
+ ]
@@ -0,0 +1,7 @@
1
+ """Packaged Alembic migrations.
2
+
3
+ Shipped inside the wheel so `rootsign-admin init` works for users who
4
+ `pip install rootsign` without cloning the repo. `rootsign/cli.py` builds
5
+ an `alembic.config.Config` programmatically and resolves this directory
6
+ via `importlib.resources` — no `alembic.ini` lookup, no cwd assumption.
7
+ """
@@ -0,0 +1,65 @@
1
+ """Alembic env. Uses the SYNC psycopg2 URL because Alembic is sync.
2
+
3
+ Importing `rootsign.models` registers all 7 ORM models on `Base.metadata`,
4
+ which Alembic uses for autogenerate diff support.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ from logging.config import fileConfig
11
+
12
+ from sqlalchemy import engine_from_config, pool
13
+
14
+ from alembic import context
15
+
16
+ # Import side effect: register all models on Base.metadata.
17
+ from rootsign import models # noqa: F401
18
+ from rootsign.config import settings
19
+ from rootsign.database import Base
20
+
21
+ config = context.config
22
+
23
+ if config.config_file_name is not None:
24
+ fileConfig(config.config_file_name)
25
+
26
+ # Allow `-x db=test` to target the test database.
27
+ x_args = context.get_x_argument(as_dictionary=True)
28
+ target_db = x_args.get("db", "dev")
29
+ if target_db == "test":
30
+ sync_url = settings.TEST_DATABASE_URL_SYNC
31
+ else:
32
+ sync_url = os.environ.get("ALEMBIC_DATABASE_URL_SYNC", settings.DATABASE_URL_SYNC)
33
+
34
+ config.set_main_option("sqlalchemy.url", sync_url)
35
+
36
+ target_metadata = Base.metadata
37
+
38
+
39
+ def run_migrations_offline() -> None:
40
+ context.configure(
41
+ url=sync_url,
42
+ target_metadata=target_metadata,
43
+ literal_binds=True,
44
+ dialect_opts={"paramstyle": "named"},
45
+ )
46
+ with context.begin_transaction():
47
+ context.run_migrations()
48
+
49
+
50
+ def run_migrations_online() -> None:
51
+ connectable = engine_from_config(
52
+ config.get_section(config.config_ini_section, {}),
53
+ prefix="sqlalchemy.",
54
+ poolclass=pool.NullPool,
55
+ )
56
+ with connectable.connect() as connection:
57
+ context.configure(connection=connection, target_metadata=target_metadata)
58
+ with context.begin_transaction():
59
+ context.run_migrations()
60
+
61
+
62
+ if context.is_offline_mode():
63
+ run_migrations_offline()
64
+ else:
65
+ run_migrations_online()
@@ -0,0 +1,25 @@
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+ ${imports if imports else ""}
13
+
14
+ revision: str = ${repr(up_revision)}
15
+ down_revision: Union[str, None] = ${repr(down_revision)}
16
+ branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
17
+ depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
18
+
19
+
20
+ def upgrade() -> None:
21
+ ${upgrades if upgrades else "pass"}
22
+
23
+
24
+ def downgrade() -> None:
25
+ raise RuntimeError("Downgrade not permitted — forward-only migration policy")
@@ -0,0 +1,387 @@
1
+ """Initial schema — 7 entities + TimescaleDB hypertable on actions.
2
+
3
+ Revision ID: 0001_initial_schema
4
+ Revises:
5
+ Create Date: 2026-05-19
6
+
7
+ This migration creates the full Phase 0 schema in one shot. Per the spec:
8
+ * `actions` is converted to a TimescaleDB hypertable partitioned by `timestamp`
9
+ * Compression policy applied to chunks > 30 days
10
+ * downgrade() raises — forward-only migration policy
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Sequence, Union
16
+
17
+ import sqlalchemy as sa
18
+ from sqlalchemy.dialects import postgresql
19
+
20
+ from alembic import op
21
+
22
+ revision: str = "0001_initial_schema"
23
+ down_revision: Union[str, None] = None
24
+ branch_labels: Union[str, Sequence[str], None] = None
25
+ depends_on: Union[str, Sequence[str], None] = None
26
+
27
+
28
+ def upgrade() -> None:
29
+ op.execute("CREATE EXTENSION IF NOT EXISTS timescaledb")
30
+
31
+ # ---------- agents ----------
32
+ op.create_table(
33
+ "agents",
34
+ sa.Column("agent_id", postgresql.UUID(as_uuid=True), primary_key=True),
35
+ sa.Column("name", sa.String(200), nullable=False),
36
+ sa.Column("owner", sa.String(200), nullable=False),
37
+ sa.Column("description", sa.String(2000), nullable=True),
38
+ sa.Column("environment", sa.String(32), nullable=False),
39
+ sa.Column("risk_tier", sa.String(32), nullable=False),
40
+ sa.Column(
41
+ "permitted_tools",
42
+ postgresql.ARRAY(sa.String()),
43
+ nullable=False,
44
+ server_default="{}",
45
+ ),
46
+ sa.Column(
47
+ "regulatory_categories",
48
+ postgresql.ARRAY(sa.String()),
49
+ nullable=False,
50
+ server_default="{}",
51
+ ),
52
+ sa.Column("framework", sa.String(32), nullable=False),
53
+ sa.Column("model_version", sa.String(100), nullable=True),
54
+ sa.Column(
55
+ "created_at",
56
+ sa.DateTime(timezone=True),
57
+ nullable=False,
58
+ server_default=sa.text("now()"),
59
+ ),
60
+ sa.Column(
61
+ "updated_at",
62
+ sa.DateTime(timezone=True),
63
+ nullable=False,
64
+ server_default=sa.text("now()"),
65
+ ),
66
+ sa.Column(
67
+ "is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")
68
+ ),
69
+ sa.Column("metadata", postgresql.JSONB(), nullable=True),
70
+ sa.UniqueConstraint("name", name="uq_agents_name"),
71
+ sa.CheckConstraint(
72
+ "environment IN ('development','staging','production')",
73
+ name="ck_agents_environment",
74
+ ),
75
+ sa.CheckConstraint(
76
+ "risk_tier IN ('low','medium','high','critical')",
77
+ name="ck_agents_risk_tier",
78
+ ),
79
+ sa.CheckConstraint(
80
+ "framework IN ('langgraph','crewai','autogen','custom','unknown')",
81
+ name="ck_agents_framework",
82
+ ),
83
+ )
84
+ op.create_index(
85
+ "ix_agents_environment_is_active", "agents", ["environment", "is_active"]
86
+ )
87
+ op.create_index("ix_agents_risk_tier", "agents", ["risk_tier"])
88
+ op.create_index("ix_agents_is_active", "agents", ["is_active"])
89
+
90
+ # ---------- policies (created early — Action.policy_id FKs into it) ----------
91
+ op.create_table(
92
+ "policies",
93
+ sa.Column("policy_id", postgresql.UUID(as_uuid=True), primary_key=True),
94
+ sa.Column("name", sa.String(200), nullable=False),
95
+ sa.Column("rule_text", sa.String(50000), nullable=False),
96
+ sa.Column("scope", sa.String(32), nullable=False),
97
+ sa.Column("scope_id", postgresql.UUID(as_uuid=True), nullable=True),
98
+ sa.Column(
99
+ "version", sa.Integer(), nullable=False, server_default=sa.text("1")
100
+ ),
101
+ sa.Column("regulatory_refs", postgresql.ARRAY(sa.String()), nullable=True),
102
+ sa.Column(
103
+ "enforcement_mode",
104
+ sa.String(32),
105
+ nullable=False,
106
+ server_default=sa.text("'log_only'"),
107
+ ),
108
+ sa.Column(
109
+ "is_active", sa.Boolean(), nullable=False, server_default=sa.text("true")
110
+ ),
111
+ sa.Column(
112
+ "created_at",
113
+ sa.DateTime(timezone=True),
114
+ nullable=False,
115
+ server_default=sa.text("now()"),
116
+ ),
117
+ sa.Column("created_by", sa.String(200), nullable=False),
118
+ sa.UniqueConstraint("name", "version", name="uq_policies_name_version"),
119
+ sa.CheckConstraint(
120
+ "scope IN ('agent','session','tool','global')", name="ck_policies_scope"
121
+ ),
122
+ sa.CheckConstraint(
123
+ "enforcement_mode IN ('log_only','require_approval','block')",
124
+ name="ck_policies_enforcement_mode",
125
+ ),
126
+ sa.CheckConstraint("version >= 1", name="ck_policies_version_positive"),
127
+ )
128
+
129
+ # ---------- sessions ----------
130
+ op.create_table(
131
+ "sessions",
132
+ sa.Column("session_id", postgresql.UUID(as_uuid=True), primary_key=True),
133
+ sa.Column(
134
+ "agent_id",
135
+ postgresql.UUID(as_uuid=True),
136
+ sa.ForeignKey("agents.agent_id", ondelete="CASCADE"),
137
+ nullable=False,
138
+ ),
139
+ sa.Column("user_id", sa.String(200), nullable=True),
140
+ sa.Column("objective", sa.String(2000), nullable=True),
141
+ sa.Column("status", sa.String(32), nullable=False),
142
+ sa.Column(
143
+ "start_time",
144
+ sa.DateTime(timezone=True),
145
+ nullable=False,
146
+ server_default=sa.text("now()"),
147
+ ),
148
+ sa.Column("end_time", sa.DateTime(timezone=True), nullable=True),
149
+ sa.Column(
150
+ "action_count", sa.Integer(), nullable=False, server_default=sa.text("0")
151
+ ),
152
+ sa.Column(
153
+ "decision_count",
154
+ sa.Integer(),
155
+ nullable=False,
156
+ server_default=sa.text("0"),
157
+ ),
158
+ sa.Column("chain_head_hash", sa.String(64), nullable=True),
159
+ sa.Column("chain_tail_hash", sa.String(64), nullable=True),
160
+ sa.Column("metadata", postgresql.JSONB(), nullable=True),
161
+ sa.CheckConstraint(
162
+ "status IN ('running','completed','failed','abandoned')",
163
+ name="ck_sessions_status",
164
+ ),
165
+ sa.CheckConstraint(
166
+ "action_count >= 0", name="ck_sessions_action_count_nonneg"
167
+ ),
168
+ sa.CheckConstraint(
169
+ "decision_count >= 0", name="ck_sessions_decision_count_nonneg"
170
+ ),
171
+ sa.CheckConstraint(
172
+ "end_time IS NULL OR end_time >= start_time",
173
+ name="ck_sessions_end_after_start",
174
+ ),
175
+ )
176
+ op.create_index("ix_sessions_agent_id_status", "sessions", ["agent_id", "status"])
177
+ op.create_index("ix_sessions_start_time_desc", "sessions", ["start_time"])
178
+
179
+ # ---------- decisions ----------
180
+ op.create_table(
181
+ "decisions",
182
+ sa.Column("decision_id", postgresql.UUID(as_uuid=True), primary_key=True),
183
+ sa.Column(
184
+ "session_id",
185
+ postgresql.UUID(as_uuid=True),
186
+ sa.ForeignKey("sessions.session_id", ondelete="CASCADE"),
187
+ nullable=False,
188
+ ),
189
+ sa.Column(
190
+ "policy_id",
191
+ postgresql.UUID(as_uuid=True),
192
+ sa.ForeignKey("policies.policy_id", ondelete="SET NULL"),
193
+ nullable=True,
194
+ ),
195
+ sa.Column("inputs_summary", sa.String(5000), nullable=True),
196
+ sa.Column("reasoning_summary", sa.String(10000), nullable=True),
197
+ sa.Column("selected_action", sa.String(200), nullable=False),
198
+ sa.Column("confidence", sa.Float(), nullable=True),
199
+ sa.Column(
200
+ "alternatives_considered", postgresql.ARRAY(sa.String()), nullable=True
201
+ ),
202
+ sa.Column(
203
+ "timestamp",
204
+ sa.DateTime(timezone=True),
205
+ nullable=False,
206
+ server_default=sa.text("now()"),
207
+ ),
208
+ sa.Column(
209
+ "reasoning_captured",
210
+ sa.Boolean(),
211
+ nullable=False,
212
+ server_default=sa.text("false"),
213
+ ),
214
+ sa.CheckConstraint(
215
+ "confidence IS NULL OR (confidence >= 0.0 AND confidence <= 1.0)",
216
+ name="ck_decisions_confidence_range",
217
+ ),
218
+ )
219
+ op.create_index(
220
+ "ix_decisions_session_timestamp", "decisions", ["session_id", "timestamp"]
221
+ )
222
+
223
+ # ---------- actions (hypertable) ----------
224
+ op.create_table(
225
+ "actions",
226
+ sa.Column("action_id", postgresql.UUID(as_uuid=True), nullable=False),
227
+ sa.Column(
228
+ "timestamp",
229
+ sa.DateTime(timezone=True),
230
+ nullable=False,
231
+ server_default=sa.text("now()"),
232
+ ),
233
+ sa.Column(
234
+ "session_id",
235
+ postgresql.UUID(as_uuid=True),
236
+ sa.ForeignKey("sessions.session_id", ondelete="RESTRICT"),
237
+ nullable=False,
238
+ ),
239
+ sa.Column("decision_id", postgresql.UUID(as_uuid=True), nullable=True),
240
+ sa.Column(
241
+ "policy_id",
242
+ postgresql.UUID(as_uuid=True),
243
+ sa.ForeignKey("policies.policy_id", ondelete="SET NULL"),
244
+ nullable=True,
245
+ ),
246
+ sa.Column("tool_name", sa.String(200), nullable=False),
247
+ sa.Column("input_hash", sa.String(64), nullable=False),
248
+ sa.Column("output_hash", sa.String(64), nullable=True),
249
+ sa.Column("input_redacted", postgresql.JSONB(), nullable=True),
250
+ sa.Column("output_redacted", postgresql.JSONB(), nullable=True),
251
+ sa.Column("prev_action_hash", sa.String(64), nullable=True),
252
+ sa.Column("self_hash", sa.String(64), nullable=False),
253
+ sa.Column("duration_ms", sa.Integer(), nullable=True),
254
+ sa.Column("authorization_status", sa.String(32), nullable=False),
255
+ sa.Column("sequence_number", sa.Integer(), nullable=False),
256
+ # Composite PK includes `timestamp` — required by TimescaleDB.
257
+ sa.PrimaryKeyConstraint("action_id", "timestamp", name="pk_actions"),
258
+ sa.CheckConstraint(
259
+ "authorization_status IN ("
260
+ "'auto_authorized','human_approved','human_rejected','pending','bypassed')",
261
+ name="ck_actions_authorization_status",
262
+ ),
263
+ sa.CheckConstraint("sequence_number >= 1", name="ck_actions_seq_positive"),
264
+ sa.CheckConstraint(
265
+ "duration_ms IS NULL OR duration_ms >= 0",
266
+ name="ck_actions_duration_nonneg",
267
+ ),
268
+ sa.CheckConstraint(
269
+ "length(input_hash) = 64", name="ck_actions_input_hash_length"
270
+ ),
271
+ sa.CheckConstraint(
272
+ "length(self_hash) = 64", name="ck_actions_self_hash_length"
273
+ ),
274
+ )
275
+ op.create_index("ix_actions_session_seq", "actions", ["session_id", "sequence_number"])
276
+ op.create_index("ix_actions_session_timestamp", "actions", ["session_id", "timestamp"])
277
+ op.create_index("ix_actions_tool_name", "actions", ["tool_name"])
278
+ op.create_index("ix_actions_authorization_status", "actions", ["authorization_status"])
279
+
280
+ # Convert to TimescaleDB hypertable + compression policy.
281
+ op.execute(
282
+ "SELECT create_hypertable('actions', 'timestamp', "
283
+ "chunk_time_interval => INTERVAL '7 days', if_not_exists => TRUE)"
284
+ )
285
+ op.execute(
286
+ "ALTER TABLE actions SET ("
287
+ "timescaledb.compress, "
288
+ "timescaledb.compress_segmentby = 'session_id')"
289
+ )
290
+ op.execute(
291
+ "SELECT add_compression_policy('actions', INTERVAL '30 days', "
292
+ "if_not_exists => TRUE)"
293
+ )
294
+
295
+ # ---------- approvals ----------
296
+ # action_id is NOT a SQL FK because actions has a composite PK (action_id, timestamp)
297
+ # — referential integrity is enforced at the CRUD layer.
298
+ op.create_table(
299
+ "approvals",
300
+ sa.Column("approval_id", postgresql.UUID(as_uuid=True), primary_key=True),
301
+ sa.Column("action_id", postgresql.UUID(as_uuid=True), nullable=False),
302
+ sa.Column("session_id", postgresql.UUID(as_uuid=True), nullable=False),
303
+ sa.Column("approver_id", sa.String(200), nullable=False),
304
+ sa.Column("approver_type", sa.String(32), nullable=False),
305
+ sa.Column("context_presented", postgresql.JSONB(), nullable=False),
306
+ sa.Column("decision", sa.String(32), nullable=False),
307
+ sa.Column("decision_reason", sa.String(2000), nullable=True),
308
+ sa.Column(
309
+ "timestamp",
310
+ sa.DateTime(timezone=True),
311
+ nullable=False,
312
+ server_default=sa.text("now()"),
313
+ ),
314
+ sa.Column("response_latency_ms", sa.Integer(), nullable=True),
315
+ sa.CheckConstraint(
316
+ "approver_type IN ("
317
+ "'human','automated_policy','timeout_auto_approved','timeout_auto_rejected')",
318
+ name="ck_approvals_approver_type",
319
+ ),
320
+ sa.CheckConstraint(
321
+ "decision IN ('approved','rejected','escalated')",
322
+ name="ck_approvals_decision",
323
+ ),
324
+ sa.CheckConstraint(
325
+ "response_latency_ms IS NULL OR response_latency_ms >= 0",
326
+ name="ck_approvals_latency_nonneg",
327
+ ),
328
+ )
329
+ op.create_index("ix_approvals_action_id", "approvals", ["action_id"])
330
+ op.create_index(
331
+ "ix_approvals_decision_timestamp", "approvals", ["decision", "timestamp"]
332
+ )
333
+
334
+ # ---------- incidents ----------
335
+ op.create_table(
336
+ "incidents",
337
+ sa.Column("incident_id", postgresql.UUID(as_uuid=True), primary_key=True),
338
+ sa.Column("trigger_type", sa.String(32), nullable=False),
339
+ sa.Column("severity", sa.String(32), nullable=False),
340
+ sa.Column("status", sa.String(32), nullable=False),
341
+ sa.Column(
342
+ "linked_session_ids",
343
+ postgresql.ARRAY(postgresql.UUID(as_uuid=True)),
344
+ nullable=False,
345
+ server_default="{}",
346
+ ),
347
+ sa.Column(
348
+ "linked_action_ids",
349
+ postgresql.ARRAY(postgresql.UUID(as_uuid=True)),
350
+ nullable=True,
351
+ ),
352
+ sa.Column(
353
+ "linked_decision_ids",
354
+ postgresql.ARRAY(postgresql.UUID(as_uuid=True)),
355
+ nullable=True,
356
+ ),
357
+ sa.Column("investigator_id", sa.String(200), nullable=True),
358
+ sa.Column("title", sa.String(200), nullable=False),
359
+ sa.Column("description", sa.String(10000), nullable=True),
360
+ sa.Column("resolution", sa.String(5000), nullable=True),
361
+ sa.Column(
362
+ "created_at",
363
+ sa.DateTime(timezone=True),
364
+ nullable=False,
365
+ server_default=sa.text("now()"),
366
+ ),
367
+ sa.Column("resolved_at", sa.DateTime(timezone=True), nullable=True),
368
+ sa.CheckConstraint(
369
+ "trigger_type IN ("
370
+ "'anomaly_detected','policy_violation','manual','authorization_bypass')",
371
+ name="ck_incidents_trigger_type",
372
+ ),
373
+ sa.CheckConstraint(
374
+ "severity IN ('info','low','medium','high','critical')",
375
+ name="ck_incidents_severity",
376
+ ),
377
+ sa.CheckConstraint(
378
+ "status IN ('open','investigating','resolved','closed','false_positive')",
379
+ name="ck_incidents_status",
380
+ ),
381
+ )
382
+ op.create_index("ix_incidents_severity", "incidents", ["severity"])
383
+ op.create_index("ix_incidents_status", "incidents", ["status"])
384
+
385
+
386
+ def downgrade() -> None:
387
+ raise RuntimeError("Downgrade not permitted — forward-only migration policy")
@@ -0,0 +1,56 @@
1
+ """Add parent_approval_id to approvals for escalation chain-of-custody.
2
+
3
+ Revision ID: 0002_approval_parent_id
4
+ Revises: 0001_initial_schema
5
+ Create Date: 2026-05-21
6
+
7
+ Adds the self-referential parent_approval_id FK on approvals so a follow-up
8
+ APPROVAL_RECORD that resolves a prior escalation can link back to it.
9
+
10
+ Per founder decision (feedback_req03_decisions): no CHECK constraint enforcing
11
+ escalation depth here — depth validation lives in CRUDApproval. This leaves
12
+ the DB shape ready for a future relaxation to chained escalations without
13
+ requiring a migration.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ from typing import Sequence, Union
19
+
20
+ import sqlalchemy as sa
21
+ from sqlalchemy.dialects import postgresql
22
+
23
+ from alembic import op
24
+
25
+ revision: str = "0002_approval_parent_id"
26
+ down_revision: Union[str, None] = "0001_initial_schema"
27
+ branch_labels: Union[str, Sequence[str], None] = None
28
+ depends_on: Union[str, Sequence[str], None] = None
29
+
30
+
31
+ def upgrade() -> None:
32
+ op.add_column(
33
+ "approvals",
34
+ sa.Column(
35
+ "parent_approval_id",
36
+ postgresql.UUID(as_uuid=True),
37
+ nullable=True,
38
+ ),
39
+ )
40
+ op.create_foreign_key(
41
+ "fk_approvals_parent_approval_id",
42
+ "approvals",
43
+ "approvals",
44
+ ["parent_approval_id"],
45
+ ["approval_id"],
46
+ ondelete="SET NULL",
47
+ )
48
+ op.create_index(
49
+ "ix_approvals_parent_approval_id",
50
+ "approvals",
51
+ ["parent_approval_id"],
52
+ )
53
+
54
+
55
+ def downgrade() -> None:
56
+ raise RuntimeError("Downgrade not permitted — forward-only migration policy")