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.
- rootsign/__init__.py +106 -0
- rootsign/_migrations/__init__.py +7 -0
- rootsign/_migrations/env.py +65 -0
- rootsign/_migrations/script.py.mako +25 -0
- rootsign/_migrations/versions/0001_initial_schema.py +387 -0
- rootsign/_migrations/versions/0002_approval_parent_id.py +56 -0
- rootsign/_migrations/versions/0003_action_timed_out.py +50 -0
- rootsign/_version.py +29 -0
- rootsign/cli.py +230 -0
- rootsign/config.py +32 -0
- rootsign/crud/__init__.py +28 -0
- rootsign/crud/action.py +201 -0
- rootsign/crud/agent.py +10 -0
- rootsign/crud/approval.py +298 -0
- rootsign/crud/base.py +74 -0
- rootsign/crud/decision.py +39 -0
- rootsign/crud/incident.py +10 -0
- rootsign/crud/policy.py +10 -0
- rootsign/crud/session.py +10 -0
- rootsign/database.py +32 -0
- rootsign/errors.py +173 -0
- rootsign/hashing.py +47 -0
- rootsign/ingest/__init__.py +19 -0
- rootsign/ingest/handler.py +351 -0
- rootsign/ingest/idempotency.py +66 -0
- rootsign/ingest/schemas.py +203 -0
- rootsign/models/__init__.py +19 -0
- rootsign/models/action.py +86 -0
- rootsign/models/agent.py +66 -0
- rootsign/models/approval.py +66 -0
- rootsign/models/decision.py +61 -0
- rootsign/models/incident.py +62 -0
- rootsign/models/policy.py +50 -0
- rootsign/models/session.py +63 -0
- rootsign/schemas/__init__.py +83 -0
- rootsign/schemas/action.py +57 -0
- rootsign/schemas/agent.py +66 -0
- rootsign/schemas/approval.py +42 -0
- rootsign/schemas/decision.py +34 -0
- rootsign/schemas/incident.py +62 -0
- rootsign/schemas/policy.py +48 -0
- rootsign/schemas/session.py +50 -0
- rootsign/sdk/__init__.py +40 -0
- rootsign/sdk/_async_bridge.py +50 -0
- rootsign/sdk/chain.py +151 -0
- rootsign/sdk/cli.py +300 -0
- rootsign/sdk/client.py +107 -0
- rootsign/sdk/config.py +78 -0
- rootsign/sdk/context.py +127 -0
- rootsign/sdk/decorator.py +612 -0
- rootsign/sdk/frameworks/__init__.py +6 -0
- rootsign/sdk/frameworks/crewai.py +154 -0
- rootsign/sdk/frameworks/langgraph.py +125 -0
- rootsign/sdk/hashing.py +42 -0
- rootsign/sdk/hitl.py +231 -0
- rootsign/sdk/redaction.py +228 -0
- rootsign/sdk/registration.py +73 -0
- rootsign/sdk/session.py +119 -0
- rootsign-0.1.1.dist-info/METADATA +313 -0
- rootsign-0.1.1.dist-info/RECORD +64 -0
- rootsign-0.1.1.dist-info/WHEEL +4 -0
- rootsign-0.1.1.dist-info/entry_points.txt +3 -0
- rootsign-0.1.1.dist-info/licenses/LICENSE +201 -0
- 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")
|