From 9bfefd65e50a09abeede49e9e0305fb533af07d1 Mon Sep 17 00:00:00 2001 From: Li Yuruan <1404020204@s.upc.edu.cn> Date: Wed, 26 Aug 2026 01:18:24 +0800 Subject: [PATCH 1/8] docs: design governance hardening v0.1.1 --- ...8-26-governance-hardening-v0.1.1-design.md | 234 ++++++++++++++++++ 1 file changed, 234 insertions(+) create mode 100644 docs/specs/2026-08-26-governance-hardening-v0.1.1-design.md diff --git a/docs/specs/2026-08-26-governance-hardening-v0.1.1-design.md b/docs/specs/2026-08-26-governance-hardening-v0.1.1-design.md new file mode 100644 index 0000000..82d0c0f --- /dev/null +++ b/docs/specs/2026-08-26-governance-hardening-v0.1.1-design.md @@ -0,0 +1,234 @@ +# Governance Hardening v0.1.1 Design + +## Goal + +Fix the correctness and trust-boundary defects found after the v0.1.0 review, +then publish a backward-conscious v0.1.1 patch release. + +The patch must: + +1. preserve existing build, analyze, profile, serve, playground, and demo flows; +2. restore complete lineage for statement chains in one SQL file; +3. make multi-operation rollback restore the true pre-action state; +4. prevent rolled-back executions from being treated as successful idempotent runs; +5. prevent scenario files from self-asserting authorization or reversibility; +6. verify the built wheel through an installed Quickstart in CI; +7. align documentation and capability claims with actual behavior. + +## Scope + +### Included + +- SQL parser statement-local source/target ownership. +- Sequential file-patch dry runs and rollback state. +- Action-engine idempotency state transitions. +- Authorization verifier contract and reference static grant registry. +- Adapter-generated reversibility evidence. +- Governance runner integration with the two trust verifiers. +- CI wheel installation and Quickstart execution. +- Documentation and capability-ledger corrections. +- v0.1.1 branch, pull request, release assets, and release verification. +- PyPI upload only after a credential is visible to the publishing process. + +### Excluded + +- Enterprise IAM, approval platforms, distributed locks, or remote schedulers. +- Redesigning the graph model or Explorer UI. +- Changing the v0.1.0 tag or its immutable release assets. +- Publishing an unverified package to PyPI. + +## 1. Statement-Local Lineage + +The parser must treat every table appearing in a `FROM` or `JOIN` clause as a +source for that statement, even if the same table was a target of an earlier +statement in the same file. + +The current global check against `result.target_tables` is removed. Source +deduplication remains local to the current statement. + +Acceptance case: + +```sql +INSERT INTO mid SELECT id FROM src; +INSERT INTO dst SELECT id FROM mid; +``` + +Expected table lineage: + +```text +src -> mid +mid -> dst +``` + +Self-read statements retain both `reads_from` and `writes_to`; the table-level +self-loop remains excluded by the graph projection. + +## 2. Sequential File Patches and Rollback + +`SqlFilePatchAdapter` must evaluate operations in order. + +### Dry run + +- Load each target file once into an in-memory state map. +- For each operation, validate its precondition against the simulated current + content, apply the replacement in memory, and continue. +- Reject ambiguous matches before touching disk. + +### Execution + +- Save the original content once with `setdefault`. +- Apply operations sequentially. +- Preserve the first pre-action state even when several operations target the + same file. + +### Rollback + +- Restore the first pre-action state. +- Read the file back and compare SHA256 with the original. +- Report success only when every target matches its original hash. + +## 3. Idempotency After Rollback + +Execution state is no longer represented only by a cached successful result. + +The engine maintains a terminal state per idempotency key: + +- `success`: repeat returns `noop`. +- `rolled_back`: repeat is blocked while the circuit is open. +- `rollback_failed`: repeat is blocked and requires human recovery. +- `failed`: repeat is blocked while the circuit is open. + +`rollback_execution()` replaces the cached success record with a rolled-back or +rollback-failed record. A reverted action can never return a successful `noop`. + +## 4. Authorization Trust Boundary + +Add an authorization verifier interface: + +```python +class AuthorizationVerifier(Protocol): + def verify( + self, + identity: str, + action_type: str, + requested_scope: AuthorizationScope, + ) -> AuthorizationVerification: + ... +``` + +`AuthorizationVerification` includes: + +- `verified` +- `grant_id` +- `identity` +- `granted_scope` +- `allowed_actions` +- `reason` +- `verifier` + +`decide_autonomy()` accepts a verification result rather than trusting +`authorization_scope` alone. Any non-`NONE` scope without a verified grant stops +at L2. + +The repository supplies a `StaticAuthorizationVerifier` for examples. Its grants +are code-owned policy data, not read from scenario YAML: + +- `quickstart-policy`: `sql_patch`, maximum `single_l3` +- `book-demo-policy`: `sql_patch` and `quarantine_cold_table`, maximum `single_l3` +- `video-workbench-policy`: `fix_creative_ctr_ratio`, maximum `single_l3` + +Production callers must inject their own verifier. + +## 5. Adapter-Generated Reversibility Evidence + +Scenario YAML may describe the intended action but cannot assert that rollback +was verified. + +Action adapters add: + +```python +def verify_reversibility( + operations: tuple[dict[str, Any], ...], +) -> ReversibilityEvidence: + ... +``` + +### SQL file adapter + +- Performs the sequential dry-run simulation. +- Applies and restores the operation sequence on an isolated temporary copy. +- Compares the restored copy with the original SHA256. +- Returns evidence references generated by the adapter. + +### DuckDB adapter + +- Copies the database to an isolated temporary directory. +- Executes the statements on the temporary database. +- Restores from its snapshot. +- Verifies restored bytes against the pre-execution hash. + +`GovernanceRunner` replaces any caller-supplied reversibility fields with the +adapter result before calling `decide_autonomy()`. + +## 6. Compatibility GovernanceLoop + +`sqlgraph.agent.GovernanceLoop` remains importable for v0.1.x compatibility, but +must not contain independent authorization or reversibility rules. + +Shared decision preparation moves to a helper used by both `GovernanceRunner` +and the compatibility facade. The compatibility facade is documented as +deprecated and only adapts its legacy report shape. + +## 7. CI Installed Quickstart + +After building wheel and sdist, `tools/ci_checks.sh` must: + +1. create a temporary Python virtual environment; +2. install the built wheel with dependencies; +3. run `sqlgraph governance run examples/minimal/scenario.yaml`; +4. run `sqlgraph governance verify`; +5. assert seven audit events and successful integrity; +6. delete the temporary environment. + +The Python 3.12 GitHub job executes this release gate. Python 3.10 and 3.11 +continue to run the full test suite. + +## 8. Documentation and Capability Status + +Update: + +- `CONTRIBUTING.md`: Python 3.10-3.12. +- `docs/architecture.md`: SHA256 identities and root-plus-operands expression DAG. +- `CAPABILITIES.yaml`: explicitly state that the bundled verifier and adapters are + reference implementations; production authorization remains adapter-defined. +- `docs/limitations.md`: idempotency and rollback are local-process guarantees. +- `docs/release-evidence.md`: add v0.1.1 regression and installed-wheel evidence. + +## Tests + +Required new tests: + +- chained multi-statement target-to-source lineage; +- self-read statement ownership; +- two patches on the same file restore the original content; +- sequential dry-run preconditions; +- post-verification rollback cannot return `noop`; +- unknown authorization identity is rejected; +- action outside a grant is rejected; +- known grant is accepted; +- scenario-provided reversibility booleans cannot override adapter evidence; +- SQL-file and DuckDB reversibility probes produce verified evidence; +- CI script contains and successfully runs installed-wheel Quickstart. + +All existing tests, open-source checks, golden tests, 100-SQL smoke benchmark, +and browser regressions must remain green. + +## Release + +- Branch: `fix/governance-hardening-v0.1.1` +- Version: `0.1.1` +- Pull request into `main` +- GitHub Release: `v0.1.1` +- Attach wheel, sdist, and path-independent `SHA256SUMS` +- Verify downloaded assets and a clean PyPI-style installation +- Upload to PyPI only when the publishing process can read a valid token From 040c7cbd3af0bd55911fa6ba2833a2e8f3db0662 Mon Sep 17 00:00:00 2001 From: Li Yuruan <1404020204@s.upc.edu.cn> Date: Wed, 26 Aug 2026 01:22:57 +0800 Subject: [PATCH 2/8] docs: plan governance hardening v0.1.1 --- .../2026-08-26-governance-hardening-v0.1.1.md | 450 ++++++++++++++++++ 1 file changed, 450 insertions(+) create mode 100644 docs/plans/2026-08-26-governance-hardening-v0.1.1.md diff --git a/docs/plans/2026-08-26-governance-hardening-v0.1.1.md b/docs/plans/2026-08-26-governance-hardening-v0.1.1.md new file mode 100644 index 0000000..8cf0da2 --- /dev/null +++ b/docs/plans/2026-08-26-governance-hardening-v0.1.1.md @@ -0,0 +1,450 @@ +# Governance Hardening v0.1.1 Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix the v0.1.0 lineage, rollback, idempotency, authorization, reversibility, CI, and documentation defects and publish a verified v0.1.1 patch release. + +**Architecture:** Keep existing public graph and governance modules, but move trust decisions behind explicit verifier/adapter interfaces. Parser ownership remains statement-local; action state becomes terminal-state aware; release validation installs the built wheel in a clean environment. + +**Tech Stack:** Python 3.10-3.12, SQLGlot, DuckDB, Typer, pytest, Ruff, GitHub Actions, Twine. + +--- + +### Task 1: Fix chained multi-statement lineage + +**Files:** +- Modify: `sqlgraph/parser/base.py:350-378` +- Modify: `tests/test_identity/test_determinism.py` + +- [ ] **Step 1: Add failing chained-statement tests** + +```python +def test_previous_target_can_be_next_statement_source(): + graph = build_graph( + "INSERT INTO mid SELECT id FROM src;" + "INSERT INTO dst SELECT id FROM mid;", + dialect="spark", + ) + assert _table_pairs(graph) == {("src", "mid"), ("mid", "dst")} + + +def test_self_read_keeps_read_and_write_ownership(): + graph = build_graph( + "INSERT INTO snapshot SELECT id FROM snapshot", + dialect="spark", + ) + edge_types = { + edge.edge_type + for edge in graph.edges + if edge.target_id == graph.get_node_by_name("snapshot").id + } + assert EdgeType.READS_FROM in edge_types + assert EdgeType.WRITES_TO in edge_types +``` + +- [ ] **Step 2: Verify red** + +Run: `python -m pytest tests/test_identity/test_determinism.py -q` + +Expected: chained case misses `mid -> dst`; self-read misses `reads_from`. + +- [ ] **Step 3: Remove global target suppression** + +In `_parse_select`, deduplicate only against sources added since +`_statement_source_start`. Do not suppress a real `FROM` table because it exists +in `result.target_tables`. + +- [ ] **Step 4: Verify parser and graph tests** + +Run: `python -m pytest tests/test_parser tests/test_builder tests/test_identity tests/test_graph_advanced -q` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add sqlgraph/parser/base.py tests/test_identity/test_determinism.py +git commit -m "fix: preserve chained statement lineage" +``` + +### Task 2: Fix sequential file patches and rollback + +**Files:** +- Modify: `sqlgraph/actions/adapters.py` +- Modify: `tests/test_actions/test_engine.py` +- Modify: `tests/safety/test_execution_recovery.py` + +- [ ] **Step 1: Add failing sequential-operation tests** + +```python +def test_sequential_dry_run_uses_simulated_content(tmp_path): + path = tmp_path / "q.sql" + path.write_text("A B", encoding="utf-8") + operations = ( + operation(path, "A B", "X B"), + operation(path, "X B", "X Y"), + ) + assert SqlFilePatchAdapter().dry_run(operations) + assert path.read_text() == "A B" + + +def test_multi_patch_failure_restores_true_original(tmp_path): + path = tmp_path / "q.sql" + path.write_text("A B", encoding="utf-8") + operations = ( + operation(path, "A", "X"), + operation(path, "B", "Y", inject_failure=True), + ) + with pytest.raises(AdapterExecutionError) as caught: + SqlFilePatchAdapter().execute(operations) + rollback = SqlFilePatchAdapter().rollback(caught.value.rollback_state) + assert rollback.verified + assert path.read_text() == "A B" +``` + +- [ ] **Step 2: Verify red** + +Run: `python -m pytest tests/test_actions tests/safety/test_execution_recovery.py -q` + +Expected: dry run rejects the second sequential operation or rollback restores +`X B`. + +- [ ] **Step 3: Implement sequential simulation** + +Add a private `_simulate(operations)` helper that loads each path once, validates +each operation against the simulated current content, and returns final +contents plus checks. `dry_run()` must not write files. + +- [ ] **Step 4: Preserve original state once** + +Use: + +```python +originals.setdefault(str(path), content) +``` + +Rollback verifies SHA256 for every restored path before reporting success. + +- [ ] **Step 5: Verify** + +Run: `python -m pytest tests/test_actions tests/safety/test_execution_recovery.py -q` + +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add sqlgraph/actions/adapters.py tests/test_actions tests/safety/test_execution_recovery.py +git commit -m "fix: restore original state for sequential patches" +``` + +### Task 3: Fix idempotency state after rollback + +**Files:** +- Modify: `sqlgraph/actions/engine.py` +- Modify: `tests/test_actions/test_engine.py` +- Modify: `tests/safety/test_verification_failure.py` + +- [ ] **Step 1: Add failing post-rollback test** + +```python +def test_rolled_back_execution_is_not_cached_as_success(engine, plan): + execution = engine.execute(plan) + rollback = engine.rollback_execution(plan, execution) + repeated = engine.execute(plan) + assert rollback.verified + assert repeated.status == "blocked" + assert repeated.status != "noop" +``` + +- [ ] **Step 2: Verify red** + +Run: `python -m pytest tests/test_actions tests/safety/test_verification_failure.py -q` + +Expected: repeat returns `noop`. + +- [ ] **Step 3: Record terminal state** + +`rollback_execution()` replaces `_executions[plan.idempotency_key]` with an +`ExecutionResult` whose status is `rolled_back` or `rollback_failed`. It removes +the consumed rollback state and opens the circuit. + +- [ ] **Step 4: Verify** + +Run: `python -m pytest tests/test_actions tests/safety/test_verification_failure.py -q` + +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add sqlgraph/actions/engine.py tests/test_actions tests/safety/test_verification_failure.py +git commit -m "fix: invalidate idempotency success after rollback" +``` + +### Task 4: Add verified authorization grants + +**Files:** +- Create: `sqlgraph/autonomy/authorization.py` +- Modify: `sqlgraph/autonomy/__init__.py` +- Modify: `sqlgraph/autonomy/decision.py` +- Modify: `sqlgraph/reasoning/runner.py` +- Modify: `sqlgraph/reasoning/scenario.py` +- Modify: `examples/video_commercial_warehouse/governance.py` +- Modify: `tests/test_autonomy/test_decision.py` +- Modify: `tests/safety/test_autonomy_gates.py` + +- [ ] **Step 1: Add failing authorization tests** + +```python +def test_unknown_identity_cannot_authorize_l3(): + verifier = StaticAuthorizationVerifier({}) + verification = verifier.verify( + "invented-policy", "sql_patch", AuthorizationScope.SINGLE_L3 + ) + decision = decide_autonomy(action("invented-policy"), verification) + assert decision.level == AutonomyLevel.L2_PROPOSE + assert decision.authorization_veto + + +def test_action_outside_grant_is_rejected(): + verifier = reference_authorization_verifier() + verification = verifier.verify( + "quickstart-policy", "drop_table", AuthorizationScope.SINGLE_L3 + ) + assert not verification.verified +``` + +- [ ] **Step 2: Verify red** + +Run: `python -m pytest tests/test_autonomy tests/safety/test_autonomy_gates.py -q` + +Expected: authorization verifier symbols are missing. + +- [ ] **Step 3: Implement verifier contracts** + +Create immutable `AuthorizationGrant` and `AuthorizationVerification`, a +protocol, and `StaticAuthorizationVerifier`. Scope ordering is: + +```python +NONE < SINGLE_L3 < CONTINUOUS_L5 +``` + +Verification requires known identity, allowed action, and requested scope no +greater than the grant. + +- [ ] **Step 4: Require verification in autonomy decisions** + +`decide_autonomy(action, authorization)` stops at L2 when a non-NONE request +lacks a verified grant. Decision output records grant ID and verifier. + +- [ ] **Step 5: Wire reference grants** + +Scenario execution resolves authorization through the code-owned reference +registry. Add `video-workbench-policy` to the video case. YAML identity is only +a lookup key. + +- [ ] **Step 6: Verify** + +Run: `python -m pytest tests/test_autonomy tests/safety/test_autonomy_gates.py tests/test_reasoning tests/test_video_warehouse -q` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add sqlgraph/autonomy sqlgraph/reasoning examples/video_commercial_warehouse/governance.py tests/test_autonomy tests/safety/test_autonomy_gates.py tests/test_reasoning tests/test_video_warehouse +git commit -m "fix: verify authorization grants before autonomy" +``` + +### Task 5: Generate reversibility evidence in adapters + +**Files:** +- Modify: `sqlgraph/actions/adapters.py` +- Modify: `sqlgraph/actions/engine.py` +- Modify: `sqlgraph/reasoning/runner.py` +- Modify: `sqlgraph/agent/__init__.py` +- Modify: `tests/test_actions/test_engine.py` +- Modify: `tests/test_reasoning/test_runner.py` +- Modify: `tests/safety/test_execution_recovery.py` + +- [ ] **Step 1: Add failing trust-boundary tests** + +```python +def test_runner_ignores_caller_reversibility_claim(tmp_path): + action = replace( + authorized_action(), + reversibility=ReversibilityEvidence(True, True, True), + ) + engine = ActionEngine([RejectingReversibilityAdapter()]) + result = runner(engine).run(request(action)) + assert result.decision.reversibility_veto + assert result.execution.status == "blocked" + + +def test_sql_file_adapter_verifies_recovery_on_isolated_copy(tmp_path): + evidence = SqlFilePatchAdapter().verify_reversibility(operations(tmp_path)) + assert evidence.verified + assert evidence.references +``` + +- [ ] **Step 2: Verify red** + +Run: `python -m pytest tests/test_actions tests/test_reasoning tests/safety/test_execution_recovery.py -q` + +Expected: adapters lack `verify_reversibility`, or caller claim is accepted. + +- [ ] **Step 3: Add adapter probes** + +SQL files use a temporary copy and compare the restored SHA256. DuckDB uses a +temporary database copy, executes statements, restores the snapshot, and +compares bytes. + +- [ ] **Step 4: Make runner authoritative** + +Before autonomy calculation: + +```python +verified = action_engine.verify_reversibility(adapter, operations) +action = replace(action, reversibility=verified) +``` + +Scenario-provided reversibility booleans are removed from decision input. + +- [ ] **Step 5: Reduce legacy facade duplication** + +Move shared evidence, authorization, decision, and verification preparation into +helpers under `sqlgraph/reasoning/`. `GovernanceLoop` delegates to those helpers +and only adapts the old result shape. + +- [ ] **Step 6: Verify** + +Run: `python -m pytest tests/test_actions tests/test_reasoning tests/safety tests/test_video_warehouse -q` + +Expected: PASS. + +- [ ] **Step 7: Commit** + +```bash +git add sqlgraph/actions sqlgraph/reasoning sqlgraph/agent tests/test_actions tests/test_reasoning tests/safety +git commit -m "fix: derive reversibility from action adapters" +``` + +### Task 6: Add installed-wheel CI and align documentation + +**Files:** +- Modify: `tools/ci_checks.sh` +- Modify: `.github/workflows/ci.yml` +- Modify: `pyproject.toml` +- Modify: `CONTRIBUTING.md` +- Modify: `docs/architecture.md` +- Modify: `docs/limitations.md` +- Modify: `CAPABILITIES.yaml` +- Modify: `docs/release-evidence.md` +- Create: `tests/test_release/test_installed_quickstart_gate.py` + +- [ ] **Step 1: Add failing CI-contract test** + +```python +def test_ci_installs_built_wheel_and_runs_quickstart(): + script = Path("tools/ci_checks.sh").read_text() + assert "pip install" in script + assert "dist/*.whl" in script + assert "governance run examples/minimal/scenario.yaml" in script + assert "governance verify" in script +``` + +- [ ] **Step 2: Verify red** + +Run: `python -m pytest tests/test_release/test_installed_quickstart_gate.py -q` + +Expected: FAIL. + +- [ ] **Step 3: Add clean installed-wheel gate** + +Use `mktemp -d`, `python -m venv`, install `dist/*.whl`, run and verify the +minimal scenario, assert seven audit records, and clean with `trap`. + +- [ ] **Step 4: Align docs and capability claims** + +Set version to `0.1.1`; update Python 3.10-3.12, SHA256, expression operand DAG, +reference authorization, adapter-generated reversibility, and local-process +limitations. + +- [ ] **Step 5: Run complete release checks** + +Run: `PYTHON=python bash tools/ci_checks.sh` + +Expected: all tests, 85% foundation coverage, clean-wheel Quickstart, and package +build pass. + +- [ ] **Step 6: Run scale and browser regressions** + +Run: + +```bash +python tools/benchmark_scale.py --counts 1000,10000 --output .benchmarks/v0.1.1.json +``` + +Verify governance workbench at 1440x1000 and 390x844 with no overflow or console +errors. + +- [ ] **Step 7: Commit** + +```bash +git add tools/ci_checks.sh .github/workflows/ci.yml pyproject.toml CONTRIBUTING.md docs CAPABILITIES.yaml tests/test_release +git commit -m "chore: prepare governance hardening v0.1.1" +``` + +### Task 7: Publish v0.1.1 + +**Files:** +- No source changes expected. + +- [ ] **Step 1: Audit branch** + +Run: + +```bash +git status --short +git diff origin/main...HEAD --check +python scripts/opensource_guard.py +python tools/check_release_assets.py +``` + +Expected: clean worktree and all checks pass. + +- [ ] **Step 2: Push and open PR** + +Push `fix/governance-hardening-v0.1.1`, create a PR to `main`, and wait for +Python 3.10-3.12 CI. + +- [ ] **Step 3: Merge after green CI** + +Use a merge commit, fetch `origin/main`, and verify the merge tree matches the +reviewed branch tree. + +- [ ] **Step 4: Build and verify release assets** + +Build wheel/sdist from the merged tree, install the wheel in a clean Python 3.12 +environment, run governance Quickstart, and generate path-independent +`SHA256SUMS`. + +- [ ] **Step 5: Create GitHub Release** + +Create `v0.1.1` against the merge commit and upload wheel, sdist, and +`SHA256SUMS`. Download all three and verify checksums. + +- [ ] **Step 6: Upload to PyPI when credentials are visible** + +Run: + +```bash +python -m twine check dist/sqlgraph_lineage-0.1.1* +python -m twine upload --non-interactive \ + dist/sqlgraph_lineage-0.1.1-py3-none-any.whl \ + dist/sqlgraph_lineage-0.1.1.tar.gz +``` + +Then query `https://pypi.org/pypi/sqlgraph-lineage/0.1.1/json`, install from +PyPI in a fresh environment, and run the minimal governance Quickstart. From 480efa68272a76b0f2861149a2f20ec69597b028 Mon Sep 17 00:00:00 2001 From: Li Yuruan <1404020204@s.upc.edu.cn> Date: Wed, 26 Aug 2026 01:25:16 +0800 Subject: [PATCH 3/8] fix: preserve chained statement lineage --- sqlgraph/parser/base.py | 3 +-- tests/test_identity/test_determinism.py | 26 +++++++++++++++++++++++++ 2 files changed, 27 insertions(+), 2 deletions(-) diff --git a/sqlgraph/parser/base.py b/sqlgraph/parser/base.py index f27b710..35c4d23 100644 --- a/sqlgraph/parser/base.py +++ b/sqlgraph/parser/base.py @@ -368,8 +368,7 @@ def _parse_select( getattr(self, "_statement_source_start", 0): ] ) - already_in_target = any(t["name"] == tname for t in result.target_tables) - if not already_added and not already_in_target: + if not already_added: result.source_tables.append({"name": tname, "alias": alias, "is_cte": is_cte}) if not is_cte: self._current_source_tables.append(tname) diff --git a/tests/test_identity/test_determinism.py b/tests/test_identity/test_determinism.py index 5153627..8d24121 100644 --- a/tests/test_identity/test_determinism.py +++ b/tests/test_identity/test_determinism.py @@ -34,6 +34,32 @@ def test_independent_statements_do_not_cross_link(): assert _table_pairs(graph) == {("s1", "d1"), ("s2", "d2")} +def test_previous_target_can_be_next_statement_source(): + graph = build_graph( + "INSERT INTO mid SELECT id FROM src; " + "INSERT INTO dst SELECT id FROM mid;", + dialect="spark", + ) + + assert _table_pairs(graph) == {("src", "mid"), ("mid", "dst")} + + +def test_self_read_keeps_read_and_write_ownership(): + graph = build_graph( + "INSERT INTO snapshot SELECT id FROM snapshot", + dialect="spark", + ) + table_id = graph.get_node_by_name("snapshot").id + edge_types = { + edge.edge_type + for edge in graph.edges + if edge.target_id == table_id + } + + assert EdgeType.READS_FROM in edge_types + assert EdgeType.WRITES_TO in edge_types + + def test_table_lineage_keeps_statement_provenance(): graph = build_graph( "INSERT INTO d1 SELECT * FROM s1; INSERT INTO d2 SELECT * FROM s2;", From f668bc5aac1a36685d884a5b512970b64d8560c0 Mon Sep 17 00:00:00 2001 From: Li Yuruan <1404020204@s.upc.edu.cn> Date: Wed, 26 Aug 2026 01:27:43 +0800 Subject: [PATCH 4/8] fix: restore original state for sequential patches --- sqlgraph/actions/adapters.py | 56 +++++++++++++++++++++----- tests/test_actions/test_engine.py | 65 ++++++++++++++++++++++++++++++- 2 files changed, 111 insertions(+), 10 deletions(-) diff --git a/sqlgraph/actions/adapters.py b/sqlgraph/actions/adapters.py index 7efe81e..37cbff3 100644 --- a/sqlgraph/actions/adapters.py +++ b/sqlgraph/actions/adapters.py @@ -40,19 +40,35 @@ def _sha256(text: str) -> str: class SqlFilePatchAdapter: name = "sql_file_patch" - def dry_run(self, operations: tuple[dict[str, Any], ...]) -> tuple[str, ...]: + def _simulate( + self, + operations: tuple[dict[str, Any], ...], + ) -> tuple[dict[str, str], tuple[str, ...]]: + simulated: dict[str, str] = {} checks = [] for operation in operations: path = Path(operation["path"]) - if not path.is_file(): - raise ValueError(f"SQL target does not exist: {path}") - content = path.read_text(encoding="utf-8") + path_key = str(path) + if path_key not in simulated: + if not path.is_file(): + raise ValueError(f"SQL target does not exist: {path}") + simulated[path_key] = path.read_text(encoding="utf-8") + content = simulated[path_key] if _sha256(content) != operation["expected_sha256"]: raise ValueError(f"SQL precondition hash changed: {path}") if content.count(operation["before"]) != 1: raise ValueError(f"SQL patch must match exactly once: {path}") + simulated[path_key] = content.replace( + operation["before"], + operation["after"], + 1, + ) checks.append(f"patch-ready:{path}") - return tuple(checks) + return simulated, tuple(checks) + + def dry_run(self, operations: tuple[dict[str, Any], ...]) -> tuple[str, ...]: + _, checks = self._simulate(operations) + return checks def execute( self, @@ -64,7 +80,11 @@ def execute( for operation in operations: path = Path(operation["path"]) content = path.read_text(encoding="utf-8") - originals[str(path)] = content + if _sha256(content) != operation["expected_sha256"]: + raise ValueError(f"SQL precondition hash changed: {path}") + if content.count(operation["before"]) != 1: + raise ValueError(f"SQL patch must match exactly once: {path}") + originals.setdefault(str(path), content) path.write_text( content.replace(operation["before"], operation["after"], 1), encoding="utf-8", @@ -73,18 +93,36 @@ def execute( if operation.get("inject_failure_after_apply"): raise RuntimeError("injected failure after SQL patch") except Exception as exc: - raise AdapterExecutionError(str(exc), {"originals": originals}) from exc - return tuple(changed), {"originals": originals} + raise AdapterExecutionError( + str(exc), + { + "originals": originals, + "original_hashes": { + path: _sha256(content) + for path, content in originals.items() + }, + }, + ) from exc + return tuple(changed), { + "originals": originals, + "original_hashes": { + path: _sha256(content) + for path, content in originals.items() + }, + } def rollback(self, state: dict[str, Any]): from sqlgraph.actions.model import RollbackResult originals = state.get("originals", {}) + original_hashes = state.get("original_hashes", {}) restored = [] for raw_path, content in originals.items(): path = Path(raw_path) path.write_text(content, encoding="utf-8") - if path.read_text(encoding="utf-8") != content: + restored_content = path.read_text(encoding="utf-8") + expected_hash = original_hashes.get(raw_path, _sha256(content)) + if _sha256(restored_content) != expected_hash: return RollbackResult( status="failed", verified=False, diff --git a/tests/test_actions/test_engine.py b/tests/test_actions/test_engine.py index d1177d5..b6a12e8 100644 --- a/tests/test_actions/test_engine.py +++ b/tests/test_actions/test_engine.py @@ -2,7 +2,14 @@ import hashlib -from sqlgraph.actions import ActionEngine, ActionRequest, SqlFilePatchAdapter +import pytest + +from sqlgraph.actions import ( + ActionEngine, + ActionRequest, + AdapterExecutionError, + SqlFilePatchAdapter, +) from sqlgraph.autonomy import ( AuthorizationScope, GovernanceAction, @@ -38,6 +45,25 @@ def _request(path, before, after, *, inject_failure=False): ) +def _operation( + path, + before, + after, + *, + expected_content=None, + inject_failure=False, +): + return { + "path": str(path), + "before": before, + "after": after, + "expected_sha256": hashlib.sha256( + (expected_content if expected_content is not None else before).encode() + ).hexdigest(), + "inject_failure_after_apply": inject_failure, + } + + def test_repeat_execution_is_noop(tmp_path): path = tmp_path / "query.sql" path.write_text("SELECT value * 100 FROM source", encoding="utf-8") @@ -68,3 +94,40 @@ def test_dry_run_does_not_modify_target(tmp_path): assert result.status == "ready" assert path.read_text(encoding="utf-8") == before + + +def test_sequential_dry_run_uses_simulated_content(tmp_path): + path = tmp_path / "query.sql" + path.write_text("A B", encoding="utf-8") + operations = ( + _operation(path, "A B", "X B"), + _operation(path, "X B", "X Y"), + ) + + checks = SqlFilePatchAdapter().dry_run(operations) + + assert len(checks) == 2 + assert path.read_text(encoding="utf-8") == "A B" + + +def test_multi_patch_failure_restores_true_original(tmp_path): + path = tmp_path / "query.sql" + path.write_text("A B", encoding="utf-8") + operations = ( + _operation(path, "A", "X", expected_content="A B"), + _operation( + path, + "B", + "Y", + expected_content="X B", + inject_failure=True, + ), + ) + adapter = SqlFilePatchAdapter() + + with pytest.raises(AdapterExecutionError) as caught: + adapter.execute(operations) + rollback = adapter.rollback(caught.value.rollback_state) + + assert rollback.verified + assert path.read_text(encoding="utf-8") == "A B" From ab7b084da3d3ac1c0b5ff95729a179e4efbc6142 Mon Sep 17 00:00:00 2001 From: Li Yuruan <1404020204@s.upc.edu.cn> Date: Wed, 26 Aug 2026 01:29:08 +0800 Subject: [PATCH 5/8] fix: invalidate idempotency success after rollback --- sqlgraph/actions/engine.py | 8 ++++++++ tests/test_actions/test_engine.py | 17 +++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/sqlgraph/actions/engine.py b/sqlgraph/actions/engine.py index 40fc3ec..b45ff04 100644 --- a/sqlgraph/actions/engine.py +++ b/sqlgraph/actions/engine.py @@ -134,5 +134,13 @@ def rollback_execution( f"rollback state is unavailable: {execution.execution_id}" ) result = self._adapters[plan.adapter].rollback(state) + self._executions[plan.idempotency_key] = replace( + execution, + status="rolled_back" if result.verified else "rollback_failed", + circuit_open=True, + rollback=result, + error="" if result.verified else result.error, + ) + self._rollback_states.pop(execution.execution_id, None) self.circuit_open = True return result diff --git a/tests/test_actions/test_engine.py b/tests/test_actions/test_engine.py index b6a12e8..c978ae1 100644 --- a/tests/test_actions/test_engine.py +++ b/tests/test_actions/test_engine.py @@ -83,6 +83,23 @@ def test_repeat_execution_is_noop(tmp_path): assert path.read_text(encoding="utf-8") == "SELECT value FROM source" +def test_rolled_back_execution_is_not_cached_as_success(tmp_path): + path = tmp_path / "query.sql" + before = "SELECT value * 100 FROM source" + path.write_text(before, encoding="utf-8") + engine = ActionEngine([SqlFilePatchAdapter()]) + plan = engine.plan(_request(path, before, "SELECT value FROM source")) + + execution = engine.execute(plan) + rollback = engine.rollback_execution(plan, execution) + repeated = engine.execute(plan) + + assert rollback.verified + assert repeated.status == "blocked" + assert repeated.status != "noop" + assert path.read_text(encoding="utf-8") == before + + def test_dry_run_does_not_modify_target(tmp_path): path = tmp_path / "query.sql" before = "SELECT value * 100 FROM source" From 8194162c564c93899ce9ca0c978662e96355db3b Mon Sep 17 00:00:00 2001 From: Li Yuruan <1404020204@s.upc.edu.cn> Date: Wed, 26 Aug 2026 01:37:51 +0800 Subject: [PATCH 6/8] fix: verify authorization grants before autonomy --- .../video_commercial_warehouse/governance.py | 9 +- schemas/autonomy-decision-v1.schema.json | 4 + sqlgraph/agent/__init__.py | 13 +- sqlgraph/autonomy/__init__.py | 12 ++ sqlgraph/autonomy/authorization.py | 136 ++++++++++++++++++ sqlgraph/autonomy/decision.py | 40 +++++- sqlgraph/reasoning/runner.py | 13 +- tests/safety/test_execution_recovery.py | 12 +- tests/test_actions/test_engine.py | 12 +- tests/test_autonomy/test_decision.py | 110 ++++++++++++-- tests/test_reasoning/test_runner.py | 1 + tests/test_verification/test_engine.py | 12 +- 12 files changed, 353 insertions(+), 21 deletions(-) create mode 100644 sqlgraph/autonomy/authorization.py diff --git a/examples/video_commercial_warehouse/governance.py b/examples/video_commercial_warehouse/governance.py index 52c0982..398a4c0 100644 --- a/examples/video_commercial_warehouse/governance.py +++ b/examples/video_commercial_warehouse/governance.py @@ -30,6 +30,7 @@ GovernanceAction, ReversibilityEvidence, decide_autonomy, + reference_authorization_verifier, ) from sqlgraph.contract import BusinessContract, GovernanceIssue from sqlgraph.evidence import build_evidence_subgraph @@ -349,10 +350,16 @@ def run_ctr_governance( blast_radius=min(float(blast["score"]) / 10.0, 1.0), object_risk=0.4, authorization_scope=AuthorizationScope.SINGLE_L3, + authorization_identity="video-workbench-policy", historical_reliability=0.95, roi=0.9, ) - decision = decide_autonomy(action) + authorization = reference_authorization_verifier().verify( + action.authorization_identity, + action.action_type, + action.authorization_scope, + ) + decision = decide_autonomy(action, authorization) trail = GovernanceLoop(after_graph).run( TASK_ID, TARGET_TABLE, diff --git a/schemas/autonomy-decision-v1.schema.json b/schemas/autonomy-decision-v1.schema.json index 087b98c..8cce3d3 100644 --- a/schemas/autonomy-decision-v1.schema.json +++ b/schemas/autonomy-decision-v1.schema.json @@ -12,6 +12,8 @@ "reversibility_veto", "evidence_veto", "authorization_veto", + "authorization_grant_id", + "authorization_verifier", "scoring_performed", "within_l5_scope", "policy_version", @@ -25,6 +27,8 @@ "reversibility_veto": {"type": "boolean"}, "evidence_veto": {"type": "boolean"}, "authorization_veto": {"type": "boolean"}, + "authorization_grant_id": {"type": "string"}, + "authorization_verifier": {"type": "string"}, "scoring_performed": {"type": "boolean"}, "within_l5_scope": {"type": "boolean"}, "policy_version": {"type": "string"}, diff --git a/sqlgraph/agent/__init__.py b/sqlgraph/agent/__init__.py index bed7a66..1e93993 100644 --- a/sqlgraph/agent/__init__.py +++ b/sqlgraph/agent/__init__.py @@ -5,7 +5,11 @@ from dataclasses import dataclass, field -from sqlgraph.autonomy import AutonomyLevel, decide_autonomy +from sqlgraph.autonomy import ( + AutonomyLevel, + reference_authorization_verifier, + decide_autonomy, +) from sqlgraph.evidence import assess_sufficiency, build_evidence_subgraph from sqlgraph.lineage import drilldown from sqlgraph.reasoning import ( @@ -84,7 +88,12 @@ def run( ) sufficiency = assess_sufficiency(evidence) lineage = drilldown(self.graph, source_table, target_table) - decision = decide_autonomy(action) + authorization = reference_authorization_verifier().verify( + action.authorization_identity, + action.action_type, + action.authorization_scope, + ) + decision = decide_autonomy(action, authorization) executed = ( decision.level == AutonomyLevel.L3_BOUNDED and not decision.requires_human_review diff --git a/sqlgraph/autonomy/__init__.py b/sqlgraph/autonomy/__init__.py index 5206d2a..c3c6e01 100644 --- a/sqlgraph/autonomy/__init__.py +++ b/sqlgraph/autonomy/__init__.py @@ -3,6 +3,13 @@ """Public autonomy policy interface.""" +from sqlgraph.autonomy.authorization import ( + AuthorizationGrant, + AuthorizationVerification, + AuthorizationVerifier, + StaticAuthorizationVerifier, + reference_authorization_verifier, +) from sqlgraph.autonomy.decision import ( POLICY_VERSION, AuthorizationScope, @@ -15,10 +22,15 @@ __all__ = [ "POLICY_VERSION", + "AuthorizationGrant", "AuthorizationScope", + "AuthorizationVerification", + "AuthorizationVerifier", "AutonomyDecision", "AutonomyLevel", "GovernanceAction", "ReversibilityEvidence", + "StaticAuthorizationVerifier", "decide_autonomy", + "reference_authorization_verifier", ] diff --git a/sqlgraph/autonomy/authorization.py b/sqlgraph/autonomy/authorization.py new file mode 100644 index 0000000..46a2277 --- /dev/null +++ b/sqlgraph/autonomy/authorization.py @@ -0,0 +1,136 @@ +# Copyright (c) 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: MIT + +"""Authorization verification contracts and reference grants.""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Mapping, Protocol + +from sqlgraph.autonomy.decision import AuthorizationScope + + +_SCOPE_RANK = { + AuthorizationScope.NONE: 0, + AuthorizationScope.SINGLE_L3: 1, + AuthorizationScope.CONTINUOUS_L5: 2, +} + + +@dataclass(frozen=True) +class AuthorizationGrant: + grant_id: str + identity: str + max_scope: AuthorizationScope + allowed_actions: tuple[str, ...] + + +@dataclass(frozen=True) +class AuthorizationVerification: + verified: bool + grant_id: str + identity: str + granted_scope: AuthorizationScope + allowed_actions: tuple[str, ...] + reason: str + verifier: str + + +class AuthorizationVerifier(Protocol): + def verify( + self, + identity: str, + action_type: str, + requested_scope: AuthorizationScope, + ) -> AuthorizationVerification: + ... + + +class StaticAuthorizationVerifier: + """Verify grants from code-owned policy data.""" + + name = "static-authorization-v1" + + def __init__(self, grants: Mapping[str, AuthorizationGrant]): + self._grants = dict(grants) + + def verify( + self, + identity: str, + action_type: str, + requested_scope: AuthorizationScope, + ) -> AuthorizationVerification: + grant = self._grants.get(identity) + if grant is None: + return AuthorizationVerification( + False, + "", + identity, + AuthorizationScope.NONE, + (), + "authorization identity is unknown", + self.name, + ) + if grant.identity != identity: + return AuthorizationVerification( + False, + grant.grant_id, + identity, + AuthorizationScope.NONE, + (), + "authorization grant identity does not match the registry key", + self.name, + ) + if action_type not in grant.allowed_actions: + return AuthorizationVerification( + False, + grant.grant_id, + identity, + grant.max_scope, + grant.allowed_actions, + f"action {action_type!r} is not allowed by the grant", + self.name, + ) + if _SCOPE_RANK[requested_scope] > _SCOPE_RANK[grant.max_scope]: + return AuthorizationVerification( + False, + grant.grant_id, + identity, + grant.max_scope, + grant.allowed_actions, + "requested scope exceeds the grant", + self.name, + ) + return AuthorizationVerification( + True, + grant.grant_id, + identity, + grant.max_scope, + grant.allowed_actions, + "authorization grant verified", + self.name, + ) + + +def reference_authorization_verifier() -> StaticAuthorizationVerifier: + return StaticAuthorizationVerifier({ + "quickstart-policy": AuthorizationGrant( + "grant-quickstart", + "quickstart-policy", + AuthorizationScope.SINGLE_L3, + ("sql_patch",), + ), + "book-demo-policy": AuthorizationGrant( + "grant-book-demo", + "book-demo-policy", + AuthorizationScope.SINGLE_L3, + ("sql_patch", "quarantine_cold_table"), + ), + "video-workbench-policy": AuthorizationGrant( + "grant-video-workbench", + "video-workbench-policy", + AuthorizationScope.SINGLE_L3, + ("fix_creative_ctr_ratio",), + ), + }) diff --git a/sqlgraph/autonomy/decision.py b/sqlgraph/autonomy/decision.py index 5c7cfab..1db72af 100644 --- a/sqlgraph/autonomy/decision.py +++ b/sqlgraph/autonomy/decision.py @@ -7,9 +7,13 @@ from dataclasses import asdict, dataclass, field from enum import Enum +from typing import TYPE_CHECKING from sqlgraph.identity import stable_id +if TYPE_CHECKING: + from sqlgraph.autonomy.authorization import AuthorizationVerification + POLICY_VERSION = "autonomy-v1" BLAST_RADIUS_REVIEW_THRESHOLD = 0.7 @@ -81,6 +85,8 @@ class AutonomyDecision: reversibility_veto: bool = False evidence_veto: bool = False authorization_veto: bool = False + authorization_grant_id: str = "" + authorization_verifier: str = "" scoring_performed: bool = False within_l5_scope: bool = False policy_version: str = POLICY_VERSION @@ -95,6 +101,8 @@ def __post_init__(self) -> None: str(self.reversibility_veto), str(self.evidence_veto), str(self.authorization_veto), + self.authorization_grant_id, + self.authorization_verifier, *self.reasons, )) object.__setattr__(self, "decision_id", stable_id("decision", key, 128)) @@ -105,7 +113,10 @@ def to_dict(self) -> dict: return result -def decide_autonomy(action: GovernanceAction) -> AutonomyDecision: +def decide_autonomy( + action: GovernanceAction, + authorization: "AuthorizationVerification | None" = None, +) -> AutonomyDecision: reasons = [] if not action.reversibility.verified: @@ -144,6 +155,29 @@ def decide_autonomy(action: GovernanceAction) -> AutonomyDecision: reasons=tuple(reasons), authorization_veto=True, ) + if ( + authorization is None + or not authorization.verified + or authorization.identity != action.authorization_identity + ): + reasons.append( + authorization.reason + if authorization is not None + else "authorization verification is required" + ) + return AutonomyDecision( + level=AutonomyLevel.L2_PROPOSE, + requires_human_review=True, + evidence_version=action.evidence_version, + reasons=tuple(reasons), + authorization_veto=True, + authorization_grant_id=( + authorization.grant_id if authorization is not None else "" + ), + authorization_verifier=( + authorization.verifier if authorization is not None else "" + ), + ) review_reasons = [] if action.blast_radius >= BLAST_RADIUS_REVIEW_THRESHOLD: @@ -161,6 +195,8 @@ def decide_autonomy(action: GovernanceAction) -> AutonomyDecision: requires_human_review=True, evidence_version=action.evidence_version, reasons=tuple(reasons), + authorization_grant_id=authorization.grant_id, + authorization_verifier=authorization.verifier, scoring_performed=True, ) @@ -174,6 +210,8 @@ def decide_autonomy(action: GovernanceAction) -> AutonomyDecision: requires_human_review=False, evidence_version=action.evidence_version, reasons=tuple(reasons), + authorization_grant_id=authorization.grant_id, + authorization_verifier=authorization.verifier, scoring_performed=True, within_l5_scope=within_l5, ) diff --git a/sqlgraph/reasoning/runner.py b/sqlgraph/reasoning/runner.py index 688dcdb..8db187c 100644 --- a/sqlgraph/reasoning/runner.py +++ b/sqlgraph/reasoning/runner.py @@ -17,10 +17,12 @@ ) from sqlgraph.audit import AuditEvent, AuditLog from sqlgraph.autonomy import ( + AuthorizationVerifier, AutonomyDecision, AutonomyLevel, GovernanceAction, decide_autonomy, + reference_authorization_verifier, ) from sqlgraph.baseline import BaselineManifest from sqlgraph.evidence import EvidenceBundle, EvidenceEngine, EvidenceRequest @@ -95,11 +97,15 @@ def __init__( action_engine: ActionEngine, verification_engine: VerificationEngine, audit_log: AuditLog, + authorization_verifier: AuthorizationVerifier | None = None, ): self.evidence_engine = evidence_engine self.action_engine = action_engine self.verification_engine = verification_engine self.audit_log = audit_log + self.authorization_verifier = ( + authorization_verifier or reference_authorization_verifier() + ) def run(self, request: GovernanceRequest) -> GovernanceResult: events = [] @@ -177,7 +183,12 @@ def record( evidence_version=evidence.version_id, evidence_grounded=grounded, ) - decision = decide_autonomy(action) + authorization = self.authorization_verifier.verify( + action.authorization_identity, + action.action_type, + action.authorization_scope, + ) + decision = decide_autonomy(action, authorization) record( "authorize", decision.to_dict(), diff --git a/tests/safety/test_execution_recovery.py b/tests/safety/test_execution_recovery.py index e50d70a..f765505 100644 --- a/tests/safety/test_execution_recovery.py +++ b/tests/safety/test_execution_recovery.py @@ -8,6 +8,7 @@ GovernanceAction, ReversibilityEvidence, decide_autonomy, + reference_authorization_verifier, ) @@ -15,13 +16,20 @@ def test_injected_failure_opens_circuit_and_restores_snapshot(tmp_path): path = tmp_path / "query.sql" before = "SELECT value * 100 FROM source" path.write_text(before, encoding="utf-8") - decision = decide_autonomy(GovernanceAction( + action = GovernanceAction( action_type="sql_patch", evidence_version="task-1@v1#abc", evidence_grounded=True, reversibility=ReversibilityEvidence(True, True, True), authorization_scope=AuthorizationScope.SINGLE_L3, - )) + authorization_identity="quickstart-policy", + ) + authorization = reference_authorization_verifier().verify( + action.authorization_identity, + action.action_type, + action.authorization_scope, + ) + decision = decide_autonomy(action, authorization) request = ActionRequest( task_id="task-1", baseline_id="base-1", diff --git a/tests/test_actions/test_engine.py b/tests/test_actions/test_engine.py index c978ae1..c94b3ba 100644 --- a/tests/test_actions/test_engine.py +++ b/tests/test_actions/test_engine.py @@ -15,17 +15,25 @@ GovernanceAction, ReversibilityEvidence, decide_autonomy, + reference_authorization_verifier, ) def _decision(): - return decide_autonomy(GovernanceAction( + action = GovernanceAction( action_type="sql_patch", evidence_version="task-1@v1#abc", evidence_grounded=True, reversibility=ReversibilityEvidence(True, True, True), authorization_scope=AuthorizationScope.SINGLE_L3, - )) + authorization_identity="quickstart-policy", + ) + authorization = reference_authorization_verifier().verify( + action.authorization_identity, + action.action_type, + action.authorization_scope, + ) + return decide_autonomy(action, authorization) def _request(path, before, after, *, inject_failure=False): diff --git a/tests/test_autonomy/test_decision.py b/tests/test_autonomy/test_decision.py index 8a3a195..dec88d5 100644 --- a/tests/test_autonomy/test_decision.py +++ b/tests/test_autonomy/test_decision.py @@ -3,10 +3,12 @@ import pytest from sqlgraph.autonomy import ( + AuthorizationGrant, AuthorizationScope, AutonomyLevel, GovernanceAction, ReversibilityEvidence, + StaticAuthorizationVerifier, decide_autonomy, ) @@ -28,10 +30,26 @@ def _action( rollback_verified=reversible, ), authorization_scope=scope, + authorization_identity="test-policy", roi=roi, ) +def _authorization(action): + return StaticAuthorizationVerifier({ + "test-policy": AuthorizationGrant( + grant_id="grant-test", + identity="test-policy", + max_scope=AuthorizationScope.CONTINUOUS_L5, + allowed_actions=("sql_patch",), + ) + }).verify( + action.authorization_identity, + action.action_type, + action.authorization_scope, + ) + + @pytest.mark.parametrize( ("reversible", "grounded", "scope", "expected"), [ @@ -42,14 +60,13 @@ def _action( ], ) def test_hard_gates_precede_scoring(reversible, grounded, scope, expected): - decision = decide_autonomy( - _action( - reversible=reversible, - grounded=grounded, - scope=scope, - roi=999, - ) + action = _action( + reversible=reversible, + grounded=grounded, + scope=scope, + roi=999, ) + decision = decide_autonomy(action, _authorization(action)) assert decision.level == expected if not reversible or not grounded: @@ -57,11 +74,84 @@ def test_hard_gates_precede_scoring(reversible, grounded, scope, expected): def test_continuous_scope_is_separate_from_action_depth(): - decision = decide_autonomy( - _action(scope=AuthorizationScope.CONTINUOUS_L5) - ) + action = _action(scope=AuthorizationScope.CONTINUOUS_L5) + decision = decide_autonomy(action, _authorization(action)) assert decision.level == AutonomyLevel.L3_BOUNDED assert decision.within_l5_scope assert decision.policy_version assert decision.evidence_version == "task-1@v1#abc" + + +def test_unknown_identity_cannot_authorize_l3(): + action = _action() + authorization = StaticAuthorizationVerifier({}).verify( + action.authorization_identity, + action.action_type, + action.authorization_scope, + ) + + decision = decide_autonomy(action, authorization) + + assert decision.level == AutonomyLevel.L2_PROPOSE + assert decision.authorization_veto + + +def test_action_outside_grant_is_rejected(): + verifier = StaticAuthorizationVerifier({ + "test-policy": AuthorizationGrant( + grant_id="grant-test", + identity="test-policy", + max_scope=AuthorizationScope.SINGLE_L3, + allowed_actions=("sql_patch",), + ) + }) + + verification = verifier.verify( + "test-policy", + "drop_table", + AuthorizationScope.SINGLE_L3, + ) + + assert not verification.verified + assert "not allowed" in verification.reason + + +def test_scope_outside_grant_is_rejected(): + verifier = StaticAuthorizationVerifier({ + "test-policy": AuthorizationGrant( + grant_id="grant-test", + identity="test-policy", + max_scope=AuthorizationScope.SINGLE_L3, + allowed_actions=("sql_patch",), + ) + }) + + verification = verifier.verify( + "test-policy", + "sql_patch", + AuthorizationScope.CONTINUOUS_L5, + ) + + assert not verification.verified + assert "exceeds" in verification.reason + + +def test_grant_identity_must_match_registry_key(): + verifier = StaticAuthorizationVerifier({ + "test-policy": AuthorizationGrant( + grant_id="grant-test", + identity="different-policy", + max_scope=AuthorizationScope.SINGLE_L3, + allowed_actions=("sql_patch",), + ) + }) + + verification = verifier.verify( + "test-policy", + "sql_patch", + AuthorizationScope.SINGLE_L3, + ) + + assert not verification.verified + assert "identity" in verification.reason diff --git a/tests/test_reasoning/test_runner.py b/tests/test_reasoning/test_runner.py index 2bb93d6..0e12b2e 100644 --- a/tests/test_reasoning/test_runner.py +++ b/tests/test_reasoning/test_runner.py @@ -39,6 +39,7 @@ def test_runner_exports_all_seven_steps(tmp_path): evidence_grounded=True, reversibility=ReversibilityEvidence(True, True, True), authorization_scope=AuthorizationScope.SINGLE_L3, + authorization_identity="quickstart-policy", ) request = GovernanceRequest( task_id="task-1", diff --git a/tests/test_verification/test_engine.py b/tests/test_verification/test_engine.py index 7559aa4..7075a5b 100644 --- a/tests/test_verification/test_engine.py +++ b/tests/test_verification/test_engine.py @@ -8,6 +8,7 @@ GovernanceAction, ReversibilityEvidence, decide_autonomy, + reference_authorization_verifier, ) from sqlgraph.verification import ( LayerResult, @@ -21,13 +22,20 @@ def _executed_patch(tmp_path): before = "INSERT INTO dst SELECT value * 100 AS value FROM src" after = "INSERT INTO dst SELECT value AS value FROM src" path.write_text(before, encoding="utf-8") - decision = decide_autonomy(GovernanceAction( + action = GovernanceAction( action_type="sql_patch", evidence_version="task-1@v1#abc", evidence_grounded=True, reversibility=ReversibilityEvidence(True, True, True), authorization_scope=AuthorizationScope.SINGLE_L3, - )) + authorization_identity="quickstart-policy", + ) + authorization = reference_authorization_verifier().verify( + action.authorization_identity, + action.action_type, + action.authorization_scope, + ) + decision = decide_autonomy(action, authorization) request = ActionRequest( task_id="task-1", baseline_id="base-1", From db40af678a12d0b45b1de37aabf50d446aed03e2 Mon Sep 17 00:00:00 2001 From: Li Yuruan <1404020204@s.upc.edu.cn> Date: Wed, 26 Aug 2026 01:48:09 +0800 Subject: [PATCH 7/8] fix: derive reversibility from action adapters --- .../caliber_consistency/scenario.yaml | 6 - .../cold_table_retirement/scenario.yaml | 6 - .../irreversible_drop/scenario.yaml | 5 - examples/minimal/scenario.yaml | 6 - .../video_commercial_warehouse/governance.py | 10 +- sqlgraph/actions/adapters.py | 152 +++++++++++++++++- sqlgraph/actions/engine.py | 17 +- sqlgraph/agent/__init__.py | 23 ++- sqlgraph/reasoning/__init__.py | 2 + sqlgraph/reasoning/policy.py | 33 ++++ sqlgraph/reasoning/runner.py | 15 +- sqlgraph/reasoning/scenario.py | 11 +- tests/test_actions/test_engine.py | 66 ++++++++ tests/test_integration/test_book_cases.py | 3 +- tests/test_reasoning/test_runner.py | 38 ++++- 15 files changed, 334 insertions(+), 59 deletions(-) create mode 100644 sqlgraph/reasoning/policy.py diff --git a/examples/book_cases/caliber_consistency/scenario.yaml b/examples/book_cases/caliber_consistency/scenario.yaml index f1467c2..284f971 100644 --- a/examples/book_cases/caliber_consistency/scenario.yaml +++ b/examples/book_cases/caliber_consistency/scenario.yaml @@ -16,12 +16,6 @@ action: object_risk: 0.3 historical_reliability: 0.95 roi: 0.9 - reversibility: - state_restorable: true - external_effects_controlled: true - rollback_verified: true - references: - - file-content-backup after: | INSERT INTO ads_creative_report SELECT clicks / impressions AS ctr diff --git a/examples/book_cases/cold_table_retirement/scenario.yaml b/examples/book_cases/cold_table_retirement/scenario.yaml index e04492f..c7fd07c 100644 --- a/examples/book_cases/cold_table_retirement/scenario.yaml +++ b/examples/book_cases/cold_table_retirement/scenario.yaml @@ -16,12 +16,6 @@ action: object_risk: 0.2 historical_reliability: 0.9 roi: 0.7 - reversibility: - state_restorable: true - external_effects_controlled: true - rollback_verified: true - references: - - file-content-backup after: | INSERT INTO cold_table_archive SELECT event_id, event_date diff --git a/examples/book_cases/irreversible_drop/scenario.yaml b/examples/book_cases/irreversible_drop/scenario.yaml index 119e73c..0fb4b2b 100644 --- a/examples/book_cases/irreversible_drop/scenario.yaml +++ b/examples/book_cases/irreversible_drop/scenario.yaml @@ -16,11 +16,6 @@ action: object_risk: 0.1 historical_reliability: 1.0 roi: 1.0 - reversibility: - state_restorable: false - external_effects_controlled: false - rollback_verified: false - references: [] after: | DROP TABLE critical_orders; runtime: diff --git a/examples/minimal/scenario.yaml b/examples/minimal/scenario.yaml index 8a2a42c..2b59268 100644 --- a/examples/minimal/scenario.yaml +++ b/examples/minimal/scenario.yaml @@ -16,12 +16,6 @@ action: object_risk: 0.1 historical_reliability: 0.95 roi: 0.8 - reversibility: - state_restorable: true - external_effects_controlled: true - rollback_verified: true - references: - - file-content-backup after: | INSERT INTO dst_metric SELECT clicks / impressions AS ctr diff --git a/examples/video_commercial_warehouse/governance.py b/examples/video_commercial_warehouse/governance.py index 398a4c0..daa1201 100644 --- a/examples/video_commercial_warehouse/governance.py +++ b/examples/video_commercial_warehouse/governance.py @@ -29,8 +29,6 @@ AuthorizationScope, GovernanceAction, ReversibilityEvidence, - decide_autonomy, - reference_authorization_verifier, ) from sqlgraph.contract import BusinessContract, GovernanceIssue from sqlgraph.evidence import build_evidence_subgraph @@ -354,12 +352,6 @@ def run_ctr_governance( historical_reliability=0.95, roi=0.9, ) - authorization = reference_authorization_verifier().verify( - action.authorization_identity, - action.action_type, - action.authorization_scope, - ) - decision = decide_autonomy(action, authorization) trail = GovernanceLoop(after_graph).run( TASK_ID, TARGET_TABLE, @@ -430,7 +422,7 @@ def run_ctr_governance( "evidence": _evidence_payload(evidence), "contract": asdict(contract), "governance_issue": issue.to_dict() if issue else None, - "decision": decision.to_dict(), + "decision": trail.decision, "runtime": { "source": "duckdb_query", "thresholds": list(thresholds), diff --git a/sqlgraph/actions/adapters.py b/sqlgraph/actions/adapters.py index 37cbff3..05e3820 100644 --- a/sqlgraph/actions/adapters.py +++ b/sqlgraph/actions/adapters.py @@ -7,9 +7,12 @@ import hashlib import shutil +import tempfile from pathlib import Path from typing import Any, Protocol +from sqlgraph.autonomy import ReversibilityEvidence + class AdapterExecutionError(RuntimeError): def __init__(self, message: str, rollback_state: dict[str, Any]): @@ -20,6 +23,12 @@ def __init__(self, message: str, rollback_state: dict[str, Any]): class ActionAdapter(Protocol): name: str + def verify_reversibility( + self, + operations: tuple[dict[str, Any], ...], + ) -> ReversibilityEvidence: + ... + def dry_run(self, operations: tuple[dict[str, Any], ...]) -> tuple[str, ...]: ... @@ -37,9 +46,78 @@ def _sha256(text: str) -> str: return hashlib.sha256(text.encode("utf-8")).hexdigest() +def _probe_failure(adapter: str, reason: str) -> ReversibilityEvidence: + return ReversibilityEvidence( + False, + False, + False, + (f"adapter:{adapter}", f"probe-error:{reason}"), + ) + + class SqlFilePatchAdapter: name = "sql_file_patch" + def verify_reversibility( + self, + operations: tuple[dict[str, Any], ...], + ) -> ReversibilityEvidence: + if not operations: + return _probe_failure(self.name, "no-operations") + with tempfile.TemporaryDirectory( + prefix="sqlgraph-file-probe-" + ) as raw_directory: + directory = Path(raw_directory) + copies: dict[str, Path] = {} + original_hashes: dict[str, str] = {} + probe_operations = [] + try: + for operation in operations: + source = Path(operation["path"]).resolve() + source_key = str(source) + if source_key not in copies: + probe = directory / ( + f"{len(copies):04d}-{source.name}" + ) + shutil.copy2(source, probe) + copies[source_key] = probe + original_hashes[source_key] = _sha256( + source.read_text(encoding="utf-8") + ) + probe_operation = dict(operation) + probe_operation["path"] = str(copies[source_key]) + probe_operation.pop("inject_failure_after_apply", None) + probe_operations.append(probe_operation) + + normalized = tuple(probe_operations) + self.dry_run(normalized) + _, rollback_state = self.execute(normalized) + rollback = self.rollback(rollback_state) + restored = rollback.verified and all( + _sha256(probe.read_text(encoding="utf-8")) + == original_hashes[source] + for source, probe in copies.items() + ) + source_unchanged = all( + _sha256(Path(source).read_text(encoding="utf-8")) + == digest + for source, digest in original_hashes.items() + ) + except Exception as exc: + return _probe_failure(self.name, type(exc).__name__) + + verified = restored and source_unchanged + return ReversibilityEvidence( + state_restorable=verified, + external_effects_controlled=True, + rollback_verified=verified, + references=( + "adapter:sql_file_patch", + "probe:isolated-copy", + *(f"rollback-sha256:{digest}" for digest in original_hashes.values()), + ), + ) + def _simulate( self, operations: tuple[dict[str, Any], ...], @@ -140,6 +218,74 @@ def rollback(self, state: dict[str, Any]): class DuckDBTaskAdapter: name = "duckdb_tasks" + def verify_reversibility( + self, + operations: tuple[dict[str, Any], ...], + ) -> ReversibilityEvidence: + if not operations: + return _probe_failure(self.name, "no-operations") + with tempfile.TemporaryDirectory( + prefix="sqlgraph-duckdb-probe-" + ) as raw_directory: + directory = Path(raw_directory) + copies: dict[str, Path] = {} + snapshots: dict[str, Path] = {} + original_hashes: dict[str, str] = {} + probe_operations = [] + try: + for operation in operations: + source = Path(operation["database_path"]).resolve() + source_key = str(source) + if source_key not in copies: + probe = directory / ( + f"{len(copies):04d}-{source.name}" + ) + snapshot = directory / ( + f"{len(copies):04d}-{source.name}.snapshot" + ) + shutil.copy2(source, probe) + copies[source_key] = probe + snapshots[source_key] = snapshot + original_hashes[source_key] = hashlib.sha256( + source.read_bytes() + ).hexdigest() + probe_operation = dict(operation) + probe_operation["database_path"] = str(copies[source_key]) + probe_operation["snapshot_path"] = str( + snapshots[source_key] + ) + probe_operation.pop("inject_failure_after_apply", None) + probe_operations.append(probe_operation) + + normalized = tuple(probe_operations) + self.dry_run(normalized) + _, rollback_state = self.execute(normalized) + rollback = self.rollback(rollback_state) + restored = rollback.verified and all( + hashlib.sha256(probe.read_bytes()).hexdigest() + == original_hashes[source] + for source, probe in copies.items() + ) + source_unchanged = all( + hashlib.sha256(Path(source).read_bytes()).hexdigest() + == digest + for source, digest in original_hashes.items() + ) + except Exception as exc: + return _probe_failure(self.name, type(exc).__name__) + + verified = restored and source_unchanged + return ReversibilityEvidence( + state_restorable=verified, + external_effects_controlled=True, + rollback_verified=verified, + references=( + "adapter:duckdb_tasks", + "probe:isolated-copy", + *(f"rollback-sha256:{digest}" for digest in original_hashes.values()), + ), + ) + def dry_run(self, operations: tuple[dict[str, Any], ...]) -> tuple[str, ...]: checks = [] for operation in operations: @@ -167,8 +313,10 @@ def execute( "snapshot_path", f"{database}.sqlgraph-backup", )).resolve() - shutil.copy2(database, snapshot) - snapshots[str(database)] = str(snapshot) + database_key = str(database) + if database_key not in snapshots: + shutil.copy2(database, snapshot) + snapshots[database_key] = str(snapshot) with duckdb.connect(str(database)) as connection: connection.execute("BEGIN") for statement in operation["statements"]: diff --git a/sqlgraph/actions/engine.py b/sqlgraph/actions/engine.py index b45ff04..f85f998 100644 --- a/sqlgraph/actions/engine.py +++ b/sqlgraph/actions/engine.py @@ -15,7 +15,7 @@ DryRunResult, ExecutionResult, ) -from sqlgraph.autonomy import AutonomyLevel +from sqlgraph.autonomy import AutonomyLevel, ReversibilityEvidence from sqlgraph.identity import stable_id @@ -26,6 +26,21 @@ def __init__(self, adapters: list[ActionAdapter]): self._rollback_states: dict[str, dict] = {} self.circuit_open = False + def verify_reversibility( + self, + adapter: str, + operations: tuple[dict, ...], + ) -> ReversibilityEvidence: + implementation = self._adapters.get(adapter) + if implementation is None: + return ReversibilityEvidence( + False, + False, + False, + (f"adapter:unknown:{adapter}",), + ) + return implementation.verify_reversibility(operations) + def plan(self, request: ActionRequest) -> ActionPlan: decision = request.decision if decision.level != AutonomyLevel.L3_BOUNDED: diff --git a/sqlgraph/agent/__init__.py b/sqlgraph/agent/__init__.py index 1e93993..a96455a 100644 --- a/sqlgraph/agent/__init__.py +++ b/sqlgraph/agent/__init__.py @@ -6,9 +6,9 @@ from dataclasses import dataclass, field from sqlgraph.autonomy import ( + AuthorizationVerifier, AutonomyLevel, reference_authorization_verifier, - decide_autonomy, ) from sqlgraph.evidence import assess_sufficiency, build_evidence_subgraph from sqlgraph.lineage import drilldown @@ -17,6 +17,7 @@ GovernanceRequest, GovernanceResult, GovernanceRunner, + prepare_autonomy_decision, ) from sqlgraph.verify import verify @@ -65,10 +66,17 @@ def replay(self) -> list[str]: class GovernanceLoop: - """Compatibility facade for the original graph-only demonstration.""" + """Deprecated compatibility facade for the graph-only demonstration.""" - def __init__(self, graph): + def __init__( + self, + graph, + authorization_verifier: AuthorizationVerifier | None = None, + ): self.graph = graph + self.authorization_verifier = ( + authorization_verifier or reference_authorization_verifier() + ) def run( self, @@ -88,12 +96,11 @@ def run( ) sufficiency = assess_sufficiency(evidence) lineage = drilldown(self.graph, source_table, target_table) - authorization = reference_authorization_verifier().verify( - action.authorization_identity, - action.action_type, - action.authorization_scope, + action, decision = prepare_autonomy_decision( + action, + self.authorization_verifier, + action.reversibility, ) - decision = decide_autonomy(action, authorization) executed = ( decision.level == AutonomyLevel.L3_BOUNDED and not decision.requires_human_review diff --git a/sqlgraph/reasoning/__init__.py b/sqlgraph/reasoning/__init__.py index 649a673..32b5410 100644 --- a/sqlgraph/reasoning/__init__.py +++ b/sqlgraph/reasoning/__init__.py @@ -3,6 +3,7 @@ """Public seven-step governance runner interface.""" +from sqlgraph.reasoning.policy import prepare_autonomy_decision from sqlgraph.reasoning.runner import ( STEPS, GovernanceRequest, @@ -15,4 +16,5 @@ "GovernanceRequest", "GovernanceResult", "GovernanceRunner", + "prepare_autonomy_decision", ] diff --git a/sqlgraph/reasoning/policy.py b/sqlgraph/reasoning/policy.py new file mode 100644 index 0000000..12bcf25 --- /dev/null +++ b/sqlgraph/reasoning/policy.py @@ -0,0 +1,33 @@ +# Copyright (c) 2026 ByteDance Ltd. and/or its affiliates +# SPDX-License-Identifier: MIT + +"""Shared preparation for evidence-bound autonomy decisions.""" + +from __future__ import annotations + +from dataclasses import replace + +from sqlgraph.autonomy import ( + AuthorizationVerifier, + AutonomyDecision, + GovernanceAction, + ReversibilityEvidence, + decide_autonomy, +) + + +def prepare_autonomy_decision( + action: GovernanceAction, + authorization_verifier: AuthorizationVerifier, + reversibility: ReversibilityEvidence, +) -> tuple[GovernanceAction, AutonomyDecision]: + verified_action = replace(action, reversibility=reversibility) + authorization = authorization_verifier.verify( + verified_action.authorization_identity, + verified_action.action_type, + verified_action.authorization_scope, + ) + return verified_action, decide_autonomy( + verified_action, + authorization, + ) diff --git a/sqlgraph/reasoning/runner.py b/sqlgraph/reasoning/runner.py index 8db187c..2af911b 100644 --- a/sqlgraph/reasoning/runner.py +++ b/sqlgraph/reasoning/runner.py @@ -21,12 +21,12 @@ AutonomyDecision, AutonomyLevel, GovernanceAction, - decide_autonomy, reference_authorization_verifier, ) from sqlgraph.baseline import BaselineManifest from sqlgraph.evidence import EvidenceBundle, EvidenceEngine, EvidenceRequest from sqlgraph.graphrag import GroundedAssertion, validate_assertions +from sqlgraph.reasoning.policy import prepare_autonomy_decision from sqlgraph.verification import ( LayerResult, VerificationEngine, @@ -178,17 +178,20 @@ def record( evidence_version=evidence.version_id, ) + reversibility = self.action_engine.verify_reversibility( + request.adapter, + request.operations, + ) action = replace( request.action, evidence_version=evidence.version_id, evidence_grounded=grounded, ) - authorization = self.authorization_verifier.verify( - action.authorization_identity, - action.action_type, - action.authorization_scope, + action, decision = prepare_autonomy_decision( + action, + self.authorization_verifier, + reversibility, ) - decision = decide_autonomy(action, authorization) record( "authorize", decision.to_dict(), diff --git a/sqlgraph/reasoning/scenario.py b/sqlgraph/reasoning/scenario.py index cf58022..8d74a48 100644 --- a/sqlgraph/reasoning/scenario.py +++ b/sqlgraph/reasoning/scenario.py @@ -89,18 +89,15 @@ def run_scenario( graph = build_graph(source, dialect=scenario["dialect"]) graph.metadata["baseline_id"] = baseline.baseline_id action_data = scenario["action"] - reversibility = action_data["reversibility"] action = GovernanceAction( action_type=action_data["type"], evidence_version="pending", evidence_grounded=False, reversibility=ReversibilityEvidence( - state_restorable=bool(reversibility["state_restorable"]), - external_effects_controlled=bool( - reversibility["external_effects_controlled"] - ), - rollback_verified=bool(reversibility["rollback_verified"]), - references=tuple(reversibility.get("references", ())), + state_restorable=False, + external_effects_controlled=False, + rollback_verified=False, + references=("unverified:adapter-probe-required",), ), authorization_scope=AuthorizationScope( action_data.get("authorization_scope", "none") diff --git a/tests/test_actions/test_engine.py b/tests/test_actions/test_engine.py index c94b3ba..1070a67 100644 --- a/tests/test_actions/test_engine.py +++ b/tests/test_actions/test_engine.py @@ -8,6 +8,7 @@ ActionEngine, ActionRequest, AdapterExecutionError, + DuckDBTaskAdapter, SqlFilePatchAdapter, ) from sqlgraph.autonomy import ( @@ -156,3 +157,68 @@ def test_multi_patch_failure_restores_true_original(tmp_path): assert rollback.verified assert path.read_text(encoding="utf-8") == "A B" + + +def test_sql_file_adapter_verifies_recovery_on_isolated_copy(tmp_path): + path = tmp_path / "query.sql" + before = "SELECT value * 100 FROM source" + path.write_text(before, encoding="utf-8") + operations = ( + _operation(path, before, "SELECT value FROM source"), + ) + + evidence = SqlFilePatchAdapter().verify_reversibility(operations) + + assert evidence.verified + assert "adapter:sql_file_patch" in evidence.references + assert path.read_text(encoding="utf-8") == before + + +def test_duckdb_adapter_verifies_recovery_on_isolated_copy(tmp_path): + import duckdb + + database = tmp_path / "warehouse.duckdb" + with duckdb.connect(str(database)) as connection: + connection.execute("CREATE TABLE source AS SELECT 1 AS id") + original_hash = hashlib.sha256(database.read_bytes()).hexdigest() + + evidence = DuckDBTaskAdapter().verify_reversibility(( + { + "database_path": str(database), + "statements": ("CREATE TABLE target AS SELECT * FROM source",), + }, + { + "database_path": str(database), + "statements": ("INSERT INTO target SELECT * FROM source",), + }, + )) + + assert evidence.verified + assert "adapter:duckdb_tasks" in evidence.references + assert hashlib.sha256(database.read_bytes()).hexdigest() == original_hash + + +def test_action_engine_exposes_adapter_reversibility_evidence(tmp_path): + path = tmp_path / "query.sql" + before = "SELECT value * 100 FROM source" + path.write_text(before, encoding="utf-8") + engine = ActionEngine([SqlFilePatchAdapter()]) + + evidence = engine.verify_reversibility( + "sql_file_patch", + (_operation(path, before, "SELECT value FROM source"),), + ) + + assert evidence.verified + assert evidence.references[0] == "adapter:sql_file_patch" + + +@pytest.mark.parametrize( + "adapter", + [SqlFilePatchAdapter(), DuckDBTaskAdapter()], +) +def test_empty_operation_list_is_not_reversible(adapter): + evidence = adapter.verify_reversibility(()) + + assert not evidence.verified + assert "probe-error:no-operations" in evidence.references diff --git a/tests/test_integration/test_book_cases.py b/tests/test_integration/test_book_cases.py index 90e441d..0b1577c 100644 --- a/tests/test_integration/test_book_cases.py +++ b/tests/test_integration/test_book_cases.py @@ -26,5 +26,6 @@ def test_book_case_exports_replayable_bundle(case, outcome, tmp_path): assert AuditLog(result.audit_path).verify_integrity().valid assert len(result.events) == 7 if case == "irreversible_drop": - assert result.decision.reversibility_veto + assert not result.decision.reversibility_veto + assert result.decision.authorization_veto assert result.execution.status == "blocked" diff --git a/tests/test_reasoning/test_runner.py b/tests/test_reasoning/test_runner.py index 0e12b2e..8b0beee 100644 --- a/tests/test_reasoning/test_runner.py +++ b/tests/test_reasoning/test_runner.py @@ -16,7 +16,19 @@ from sqlgraph.verification import VerificationEngine -def test_runner_exports_all_seven_steps(tmp_path): +class RejectingReversibilityAdapter: + name = "sql_file_patch" + + def verify_reversibility(self, operations): + return ReversibilityEvidence( + False, + False, + False, + ("adapter:test-reject",), + ) + + +def _governance_case(tmp_path, adapter): path = tmp_path / "query.sql" before = "INSERT INTO dst SELECT value * 100 AS value FROM src" after = "INSERT INTO dst SELECT value AS value FROM src" @@ -66,10 +78,18 @@ def test_runner_exports_all_seven_steps(tmp_path): ) runner = GovernanceRunner( EvidenceEngine(graph), - ActionEngine([SqlFilePatchAdapter()]), + ActionEngine([adapter]), VerificationEngine(), AuditLog(tmp_path / "audit.jsonl"), ) + return runner, request, path, before + + +def test_runner_exports_all_seven_steps(tmp_path): + runner, request, _, _ = _governance_case( + tmp_path, + SqlFilePatchAdapter(), + ) result = runner.run(request) @@ -84,3 +104,17 @@ def test_runner_exports_all_seven_steps(tmp_path): ] assert result.outcome == "success" assert AuditLog(result.audit_path).verify_integrity().valid + + +def test_runner_ignores_caller_reversibility_claim(tmp_path): + runner, request, path, before = _governance_case( + tmp_path, + RejectingReversibilityAdapter(), + ) + + result = runner.run(request) + + assert result.decision.reversibility_veto + assert result.execution.status == "blocked" + assert result.decision.reasons[0].startswith("reversibility gate failed") + assert path.read_text(encoding="utf-8") == before From 3359e1f46587c610e838d9ad76fcba3bf96b6eb1 Mon Sep 17 00:00:00 2001 From: Li Yuruan <1404020204@s.upc.edu.cn> Date: Wed, 26 Aug 2026 09:50:03 +0800 Subject: [PATCH 8/8] chore: prepare governance hardening v0.1.1 --- CAPABILITIES.yaml | 12 +++---- CONTRIBUTING.md | 2 +- README.md | 2 +- docs/architecture.md | 34 +++++++++++-------- docs/limitations.md | 8 +++++ docs/release-evidence.md | 19 ++++++++--- .../templates/workbench.html | 4 +-- pyproject.toml | 2 +- sqlgraph/__init__.py | 2 +- .../test_installed_quickstart_gate.py | 11 ++++++ tests/test_video_warehouse/test_report.py | 14 ++++++++ tools/ci_checks.sh | 21 ++++++++++++ 12 files changed, 101 insertions(+), 30 deletions(-) create mode 100644 tests/test_release/test_installed_quickstart_gate.py diff --git a/CAPABILITIES.yaml b/CAPABILITIES.yaml index ea4b5ff..a9c5e57 100644 --- a/CAPABILITIES.yaml +++ b/CAPABILITIES.yaml @@ -51,18 +51,18 @@ capabilities: limitations: [Learn writes signals only and never expands authorization.] - id: autonomy-policy status: implemented - description: Evidence, authorization, reversibility, impact, risk, and reliability gates. - modules: [sqlgraph/autonomy] - tests: [tests/test_autonomy, tests/safety/test_autonomy_gates.py] + description: Evidence, verified grants, adapter-derived reversibility, impact, risk, and reliability gates. + modules: [sqlgraph/autonomy, sqlgraph/reasoning] + tests: [tests/test_autonomy, tests/test_reasoning/test_runner.py, tests/safety/test_autonomy_gates.py] examples: [examples/book_cases/irreversible_drop] - limitations: [Thresholds are reference defaults and must be calibrated per deployment.] + limitations: [The bundled static grant registry is a reference implementation; production deployments must inject their own authorization verifier.] - id: reversible-actions status: implemented - description: Idempotent file and DuckDB actions with dry run, circuit breaker, and verified rollback. + description: Idempotent file and DuckDB actions with isolated reversibility probes, dry run, circuit breaker, and verified rollback. modules: [sqlgraph/actions] tests: [tests/test_actions, tests/safety/test_execution_recovery.py] examples: [examples/book_cases/cold_table_retirement] - limitations: [Only local SQL files and DuckDB are included adapters.] + limitations: [Only local SQL files and DuckDB are included; idempotency and rollback state are local-process guarantees.] - id: three-layer-verification status: implemented description: Actual code comparison, independent graph rebuild, and runtime checks. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 170cef6..b348053 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -76,7 +76,7 @@ cd SqlGraph pip install -e ".[all]" ``` -SqlGraph targets **Python 3.9 through 3.12** (CI runs the same version matrix). +SqlGraph targets **Python 3.10 through 3.12** (CI runs the same version matrix). ## Running tests diff --git a/README.md b/README.md index c1b2582..19759eb 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # SqlGraph [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) -[![Python](https://img.shields.io/badge/python-3.9--3.12-blue.svg)](https://www.python.org/) +[![Python](https://img.shields.io/badge/python-3.10--3.12-blue.svg)](https://www.python.org/) [![CI](https://github.com/Liyurun/SqlGraph/actions/workflows/ci.yml/badge.svg)](https://github.com/Liyurun/SqlGraph/actions/workflows/ci.yml) [![SQLGlot](https://img.shields.io/badge/parser-SQLGlot-green.svg)](https://github.com/tobymao/sqlglot) [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](CONTRIBUTING.md) diff --git a/docs/architecture.md b/docs/architecture.md index 4d4d7fe..0f09804 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -26,28 +26,31 @@ clean boundary, so you can swap or extend any layer independently. - **`ColumnResolver`** binds every `exp.Column` to a physical `table.column`, using the query's source tables (CTE-inclusive) and the schema registry to disambiguate. Unresolvable columns fall back to `UNKNOWN.col`. -- **`expr_dag.decompose()`** turns each non-passthrough output expression into a - single **fingerprinted logic node**: +- **`expr_dag.decompose()`** preserves each non-passthrough output expression as + a stable root node, while **`decompose_operands()`** records nested operators + as a child-to-parent operand DAG: - all column references inside the expression are replaced with their resolved physical-column strings; - SQLGlot serializes the rewritten expression with `normalize=True`, giving a conservative canonical SQL string without algebraic equivalence inference; - - that canonical SQL string is hashed (SHA1, 128-bit) into the fingerprint; - - every physical column the expression reads is recorded as a dependency. + - the canonical SQL string produces a conservative 128-bit logic fingerprint; + - every physical column the expression reads is recorded as a dependency; + - nested functions and arithmetic operators are connected with + `expr_operand` edges without changing the root fingerprint. This fingerprint identifies identical physical-column-bound expression shapes across different SQL files. It intentionally does not treat `a + b` and `b + a` as equivalent. The builder combines the fingerprint with the output field name to decide Transform identity, so identical logic only collapses - when it produces the same downstream field. A whole composite expression such - as `ROUND(SUM(clicks)/COUNT(*), 4)` is **one** node — it is not decomposed into - sub-nodes. + when it produces the same downstream field. A composite expression such as + `ROUND(SUM(clicks)/COUNT(*), 4)` therefore has one stable output root plus + inspectable nested operand nodes. ## 3. Builder (`sqlgraph/builder`) - **`GraphBuilder`** materializes the graph from parse results: - - tables and columns get **deterministic IDs** (96-bit SHA1 of their content - key) so the same entity is stable across files and runs; + - graph nodes and edges use deterministic `id-v2` identities derived from + SHA256 semantic keys, so the same entity is stable across files and runs; - transformation nodes are deduplicated by `expression fingerprint + output field name` (`_ensure_expr_node`); - for each output column it adds `contains` (SQL→expr), @@ -74,6 +77,7 @@ An in-memory **`PropertyGraph`** of typed nodes and edges. | `has_column` | table → its column | | `contains` | SQL → a transformation it defines | | `compute_dependency` | physical column → transformation that consumes it | +| `expr_operand` | nested transformation → parent transformation | | `produces` | transformation → output column | | `table_lineage` | source table → target table (cross-SQL) | @@ -89,11 +93,12 @@ An in-memory **`PropertyGraph`** of typed nodes and edges. ## Design principles -- **Determinism** — same SQL ⇒ same graph ⇒ same node IDs. Diffs are meaningful. +- **Determinism** — same SQL ⇒ same graph ⇒ the same SHA256-derived `id-v2` + node and edge IDs. Diffs are meaningful. - **Physical precision** — node identity binds to resolved physical columns, not text, so lookalike expressions on different columns stay distinct. -- **Scale** — 96/128-bit fingerprints keep collisions negligible even for - warehouses with millions of columns. +- **Scale** — 96/128-bit truncated identities keep collisions negligible for + the reference warehouse scale, with collision detection during construction. ## Governance reference pipeline @@ -108,8 +113,9 @@ Baseline -> HeteroGraph/TableGraph -> Evidence/Grounding -> Autonomy - `baseline` identifies the effective input and discloses missing dependencies. - `evidence` collects an intent-bound, versioned subgraph with coverage duties. - `graphrag` rejects assertions with missing, stale, or out-of-scope citations. -- `autonomy` applies evidence, authorization, and reversibility gates before scoring. -- `actions` provides idempotent file and DuckDB adapters with verified rollback. +- `autonomy` accepts authorization only from an injected verifier before scoring. +- `actions` derives reversibility evidence from isolated file and DuckDB probes, + then provides idempotent execution with verified rollback. - `verification` compares actual code, rebuilds structure, and checks runtime effects. - `audit` appends hash-chained events; `reasoning` orchestrates the seven steps. diff --git a/docs/limitations.md b/docs/limitations.md index b585284..b78ad0b 100644 --- a/docs/limitations.md +++ b/docs/limitations.md @@ -10,6 +10,12 @@ must be supplied through adapters. - The included action adapters support local SQL files and DuckDB only. They are reference implementations, not production warehouse credentials or schedulers. +- The bundled static authorization registry is for examples only. Production + deployments must inject an IAM- or approval-backed verifier. +- Idempotency caches, circuit-breaker state, snapshots, and rollback guarantees + are local to one process and filesystem; they are not distributed transactions. +- Adapter reversibility probes validate isolated local copies. They do not prove + that arbitrary external systems or side effects can be restored. - JSONL audit storage detects local mutation, deletion, and reordering but is not a distributed consensus ledger. - Runtime verification trusts the configured runtime adapter's observations. @@ -20,3 +26,5 @@ and 10,000 statement benchmarks run in a separate workflow. - SqlGraph does not bundle an LLM, approval platform, identity provider, metadata catalog, or production scheduler. +- `sqlgraph.agent.GovernanceLoop` is a deprecated v0.1 compatibility facade. + New integrations should use `sqlgraph.reasoning.GovernanceRunner`. diff --git a/docs/release-evidence.md b/docs/release-evidence.md index f581c6e..72454c2 100644 --- a/docs/release-evidence.md +++ b/docs/release-evidence.md @@ -7,26 +7,37 @@ | Ten-minute runnable | Wheel installed in a clean Python 3.12 virtual environment; `governance run`, `verify`, and `replay` completed | Pass | | Honest capability status | `CAPABILITIES.yaml` validated against JSON Schema and repository paths | Pass | | Core safety | Evidence, Grounding, autonomy, idempotency, rollback, verification, and audit tamper tests | Pass | -| Three book cases | Caliber consistency, cold-table quarantine, irreversible drop to L4 | Pass | +| Three book cases | Caliber consistency, cold-table quarantine, and destructive action held at the verified grant boundary | Pass | | Full replay | Every case emits seven hash-chained events and passes integrity verification | Pass | | Explicit limitations | `docs/limitations.md` and capability-level limitations | Pass | ## Automated Validation - Unified release gate: `PYTHON=python bash tools/ci_checks.sh` -- Tests: 344 passed at the first complete release-gate run. +- Tests: 361 passed in the v0.1.1 complete release-gate run. - Foundation coverage: 89.29%, above the 85% gate. - Golden corpus: 5 byte-for-byte graph snapshots plus 15 syntax/dialect cases. - Package build: source distribution and wheel completed. +- Installed-wheel gate: Python 3.12 clean environment, successful minimal + governance run, valid verification, and exactly seven audit events. - Open-source guard, layering, deterministic-ID, capability, and release-asset checks passed. +## v0.1.1 Hardening + +- Chained statements retain target-to-source lineage and self-read ownership. +- Sequential patches restore the true pre-action state. +- Rolled-back executions cannot be returned as successful idempotent no-ops. +- L3 execution requires a code-owned verified authorization grant. +- Reversibility is derived from isolated adapter probes, not scenario claims. +- Release CI installs the built wheel before running the governance Quickstart. + ## Scale Evidence | SQL statements | Elapsed | Peak memory | Nodes | Edges | Artifact | Deterministic | |---:|---:|---:|---:|---:|---:|---| -| 1,000 | 997 ms | 11.45 MB | 5,003 | 9,002 | 2.52 MB | yes | -| 10,000 | 10.16 s | 113.17 MB | 50,003 | 90,002 | 25.25 MB | yes | +| 1,000 | 1.12 s | 11.45 MB | 5,003 | 9,002 | 2.52 MB | yes | +| 10,000 | 11.53 s | 112.91 MB | 50,003 | 90,002 | 25.25 MB | yes | The benchmark uses a deterministic generated SQL chain. Results are local reference measurements, not production latency guarantees. diff --git a/examples/video_commercial_warehouse/templates/workbench.html b/examples/video_commercial_warehouse/templates/workbench.html index 698d41b..b057833 100644 --- a/examples/video_commercial_warehouse/templates/workbench.html +++ b/examples/video_commercial_warehouse/templates/workbench.html @@ -225,8 +225,8 @@ .detail-head,.detail-section{padding:16px 18px;border-bottom:1px solid var(--line)} .detail h2{margin:4px 0 3px;color:#f8fafc;font-size:17px;overflow-wrap:anywhere} .detail h3{margin-bottom:9px;color:var(--accent-2);font-size:12px;letter-spacing:.08em;text-transform:uppercase} -.kv{display:grid;grid-template-columns:1fr auto;gap:8px 10px;font-size:12px} -.kv b{font-family:var(--font-mono);font-variant-numeric:tabular-nums} +.kv{display:grid;grid-template-columns:72px minmax(0,1fr);gap:8px 10px;font-size:12px} +.kv span{white-space:nowrap}.kv b{min-width:0;overflow-wrap:anywhere;font-family:var(--font-mono);font-variant-numeric:tabular-nums} .pill-list{display:flex;flex-wrap:wrap;gap:6px} .pill-list button,.pill{ min-height:30px;padding:6px 8px;border:1px solid rgba(148,163,184,.18);border-radius:8px; diff --git a/pyproject.toml b/pyproject.toml index 3a53703..0e62933 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "sqlgraph-lineage" -version = "0.1.0" +version = "0.1.1" description = "SQL-to-Knowledge-Graph engine for data warehouses: lineage, columns, and reusable transformation logic." authors = ["sqlgraph contributors"] license = "MIT" diff --git a/sqlgraph/__init__.py b/sqlgraph/__init__.py index 16af47d..2274d1d 100644 --- a/sqlgraph/__init__.py +++ b/sqlgraph/__init__.py @@ -45,4 +45,4 @@ ] # 版本号 -__version__ = "0.1.0" +__version__ = "0.1.1" diff --git a/tests/test_release/test_installed_quickstart_gate.py b/tests/test_release/test_installed_quickstart_gate.py new file mode 100644 index 0000000..5cdcbb1 --- /dev/null +++ b/tests/test_release/test_installed_quickstart_gate.py @@ -0,0 +1,11 @@ +from pathlib import Path + + +def test_ci_installs_built_wheel_and_runs_quickstart(): + script = Path("tools/ci_checks.sh").read_text(encoding="utf-8") + + assert "pip install" in script + assert "dist/*.whl" in script + assert "governance run examples/minimal/scenario.yaml" in script + assert "governance verify" in script + assert "event_count" in script diff --git a/tests/test_video_warehouse/test_report.py b/tests/test_video_warehouse/test_report.py index f3bd77a..ce57304 100644 --- a/tests/test_video_warehouse/test_report.py +++ b/tests/test_video_warehouse/test_report.py @@ -141,3 +141,17 @@ def test_report_uses_github_explorer_visual_tokens(tmp_path): assert "background:#fff" not in text assert 'content="dark light"' not in text assert load_explorer_css() in text + + +def test_report_keeps_detail_labels_readable(tmp_path): + root = tmp_path / "warehouse" + output = tmp_path / "out" + generate_sql(root) + result = run_ctr_governance(root, output, profile="smoke") + text = render_report( + result, output / "warehouse_report.html" + ).read_text(encoding="utf-8") + + assert ".kv{display:grid;grid-template-columns:72px minmax(0,1fr)" in text + assert ".kv span{white-space:nowrap}" in text + assert ".kv b{min-width:0;overflow-wrap:anywhere" in text diff --git a/tools/ci_checks.sh b/tools/ci_checks.sh index 6e2c847..28097bb 100755 --- a/tools/ci_checks.sh +++ b/tools/ci_checks.sh @@ -2,6 +2,7 @@ set -euo pipefail PYTHON="${PYTHON:-python}" +RELEASE_PYTHON="${RELEASE_PYTHON:-$PYTHON}" "$PYTHON" -m ruff check sqlgraph tests tools examples "$PYTHON" tools/check_layering.py @@ -11,4 +12,24 @@ PYTHON="${PYTHON:-python}" "$PYTHON" tools/check_release_assets.py "$PYTHON" -m pytest --cov=sqlgraph --cov-report=json:coverage.json -q "$PYTHON" tools/check_coverage.py --report coverage.json --min 85 +rm -rf dist "$PYTHON" -m build + +RELEASE_TEST_ROOT="$(mktemp -d)" +trap 'rm -rf "$RELEASE_TEST_ROOT"' EXIT +VENV_DIR="$RELEASE_TEST_ROOT/venv" +VENV_PYTHON="$VENV_DIR/bin/python" + +"$RELEASE_PYTHON" -m venv "$VENV_DIR" +"$VENV_PYTHON" -m pip install --disable-pip-version-check dist/*.whl +mkdir -p "$RELEASE_TEST_ROOT/run/examples" +cp -R examples/minimal "$RELEASE_TEST_ROOT/run/examples/minimal" +( + cd "$RELEASE_TEST_ROOT/run" + "$VENV_DIR/bin/sqlgraph" governance run examples/minimal/scenario.yaml \ + --output governance_output + "$VENV_DIR/bin/sqlgraph" governance verify governance_output + event_count="$("$VENV_PYTHON" -c \ + 'from pathlib import Path; print(len(Path("governance_output/audit.jsonl").read_text().splitlines()))')" + test "$event_count" -eq 7 +)