pythia-plsql 0.8.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. {pythia_plsql-0.8.0/scripts/pythia_plsql.egg-info → pythia_plsql-0.9.0}/PKG-INFO +2 -2
  2. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/README.md +1 -1
  3. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/pyproject.toml +1 -1
  4. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia.py +296 -34
  5. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0/scripts/pythia_plsql.egg-info}/PKG-INFO +2 -2
  6. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-apply/SKILL.md +13 -13
  7. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/tests/test_phase3.py +209 -3
  8. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/tests/test_phase5.py +2 -1
  9. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/LICENSE +0 -0
  10. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/compile-errors.sql +0 -0
  11. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/dependencies.sql +0 -0
  12. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/impact.sql +0 -0
  13. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/invalid-objects.sql +0 -0
  14. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/name-occupants.sql +0 -0
  15. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/object-names.sql +0 -0
  16. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/object-source.sql +0 -0
  17. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/plscope-enabled.sql +0 -0
  18. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/plscope-statements.sql +0 -0
  19. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/plscope-usages.sql +0 -0
  20. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/session-privileges.sql +0 -0
  21. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/similar-candidates.sql +0 -0
  22. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/queries/source.sql +0 -0
  23. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia_plsql.egg-info/SOURCES.txt +0 -0
  24. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia_plsql.egg-info/dependency_links.txt +0 -0
  25. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia_plsql.egg-info/entry_points.txt +0 -0
  26. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia_plsql.egg-info/requires.txt +0 -0
  27. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/scripts/pythia_plsql.egg-info/top_level.txt +0 -0
  28. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/setup.cfg +0 -0
  29. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-conventions/SKILL.md +0 -0
  30. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-explore/SKILL.md +0 -0
  31. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-explore/reference/data-dictionary.md +0 -0
  32. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-impact/SKILL.md +0 -0
  33. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-review/SKILL.md +0 -0
  34. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-review/reference/antipatterns.md +0 -0
  35. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-setup/SKILL.md +0 -0
  36. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-skill-author/SKILL.md +0 -0
  37. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-spec/SKILL.md +0 -0
  38. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-write/SKILL.md +0 -0
  39. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/pythia-write/reference/patterns.md +0 -0
  40. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/skills/using-pythia/SKILL.md +0 -0
  41. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/tests/test_install.py +0 -0
  42. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/tests/test_phase1.py +0 -0
  43. {pythia_plsql-0.8.0 → pythia_plsql-0.9.0}/tests/test_phase2.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/thaildhe172591/pythia
@@ -211,7 +211,7 @@ Per-group write policy, `.pythia/policy.json` (defaults shown):
211
211
  | Group | Default | Is rollback real? |
212
212
  |---|---|---|
213
213
  | `plsql_source` | `confirm` | **Yes — completely.** Source is recoverable from `ALL_SOURCE`. |
214
- | `data_dml` | `deny` | **No.** After commit only Flashback Query remains, within undo retention. |
214
+ | `data_dml` | `deny` | **No.** After commit only Flashback Query remains, within undo retention. Revalidation checks the row set *before* the write; it is not an undo. |
215
215
  | `structural` | `deny` | **Almost never.** `DROP COLUMN` is permanent; a dropped table may be in the Recycle Bin. |
216
216
  | `grants` | `deny` | Yes, but by hand. |
217
217
  | `session` | `allow` | Not needed. |
@@ -197,7 +197,7 @@ Per-group write policy, `.pythia/policy.json` (defaults shown):
197
197
  | Group | Default | Is rollback real? |
198
198
  |---|---|---|
199
199
  | `plsql_source` | `confirm` | **Yes — completely.** Source is recoverable from `ALL_SOURCE`. |
200
- | `data_dml` | `deny` | **No.** After commit only Flashback Query remains, within undo retention. |
200
+ | `data_dml` | `deny` | **No.** After commit only Flashback Query remains, within undo retention. Revalidation checks the row set *before* the write; it is not an undo. |
201
201
  | `structural` | `deny` | **Almost never.** `DROP COLUMN` is permanent; a dropped table may be in the Recycle Bin. |
202
202
  | `grants` | `deny` | Yes, but by hand. |
203
203
  | `session` | `allow` | Not needed. |
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pythia-plsql"
7
- version = "0.8.0"
7
+ version = "0.9.0"
8
8
  description = "PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback."
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -798,6 +798,165 @@ def prepare_statement(sql, group):
798
798
  return text
799
799
 
800
800
 
801
+ REVALIDATE_REFUSAL = """\
802
+ Refused: pythia cannot work out which rows this statement would touch, because
803
+ %s.
804
+ A data_dml write is approved on the rows it affects, so a row set that cannot
805
+ be measured is refused rather than guessed at.
806
+ Rewrite it as one UPDATE or DELETE over one table with one WHERE."""
807
+
808
+
809
+ def mask_literals(sql):
810
+ """A same-length copy with quoted text and comments blanked to spaces.
811
+ Length is the contract: an index found in the mask points at the same
812
+ character in the original, which is what lets dml_probe trust a keyword
813
+ scan without carrying a real parser."""
814
+ out, i, n = [], 0, len(sql)
815
+ while i < n:
816
+ if sql[i] == "'":
817
+ out.append(" ")
818
+ i += 1
819
+ while i < n: # a doubled '' simply closes and
820
+ out.append(" ") # reopens — blanked either way
821
+ i += 1
822
+ if sql[i - 1] == "'":
823
+ break
824
+ elif sql.startswith("--", i):
825
+ while i < n and sql[i] != "\n":
826
+ out.append(" ")
827
+ i += 1
828
+ elif sql.startswith("/*", i):
829
+ end = sql.find("*/", i + 2)
830
+ end = n if end < 0 else end + 2
831
+ out.append(" " * (end - i))
832
+ i = end
833
+ else:
834
+ out.append(sql[i])
835
+ i += 1
836
+ return "".join(out)
837
+
838
+
839
+ def dml_probe(stmt):
840
+ """(verb, target, predicate|None): the SELECT-able description of the rows
841
+ an UPDATE or DELETE will touch. None for INSERT — nothing exists
842
+ beforehand, so there is nothing to revalidate. Everything else is refused,
843
+ for classify()'s reason: a parser that guesses generously is a parser that
844
+ lets deny be bypassed.
845
+
846
+ target is a span of text, not a parsed identifier, so a schema qualifier
847
+ and an alias survive and a predicate written against the alias resolves."""
848
+ s = skip_leading_noise(stmt)
849
+ m = re.match(r"(insert|update|delete|merge)\b", s, re.I)
850
+ verb = m.group(1).lower() if m else ""
851
+ if verb == "insert":
852
+ return None
853
+ if verb == "merge":
854
+ sys.exit("Refused: a MERGE cannot be revalidated — the rows it touches "
855
+ "are decided by a join against its source query, so pythia "
856
+ "cannot show you the set you would be approving.\n"
857
+ "Express it as an UPDATE and/or a DELETE, or run it by hand "
858
+ "with a DBA watching.")
859
+ if re.search(r"q'", s, re.I):
860
+ sys.exit(REVALIDATE_REFUSAL % "it uses an alternative-quoted (q'...') "
861
+ "literal, which this parser does not read")
862
+ masked = mask_literals(s)
863
+ if re.search(r"\bselect\b", masked, re.I):
864
+ sys.exit(REVALIDATE_REFUSAL % "it contains a subquery, so its row set "
865
+ "is not one predicate over one table")
866
+ wheres = [w.start() for w in re.finditer(r"\bwhere\b", masked, re.I)]
867
+ if len(wheres) > 1:
868
+ sys.exit(REVALIDATE_REFUSAL % "it has more than one WHERE, so which "
869
+ "one selects the rows would be a guess")
870
+ head = re.match(r"delete\s+(?:from\s+)?" if verb == "delete"
871
+ else r"update\s+", masked, re.I)
872
+ start = head.end()
873
+ if verb == "update":
874
+ sets = re.search(r"\bset\b", masked[start:], re.I)
875
+ if not sets:
876
+ sys.exit(REVALIDATE_REFUSAL % "the UPDATE has no SET clause")
877
+ end = start + sets.start()
878
+ else:
879
+ end = wheres[0] if wheres else len(masked)
880
+ if wheres and wheres[0] < end:
881
+ sys.exit(REVALIDATE_REFUSAL % "its WHERE comes before the table it "
882
+ "reads, which this parser does not expect")
883
+ target = s[start:end].strip()
884
+ if not target:
885
+ sys.exit(REVALIDATE_REFUSAL % "no table name follows the verb")
886
+ pred = s[wheres[0] + 5:].strip() if wheres else None
887
+ return verb, target, pred
888
+
889
+
890
+ def count_sql(target, pred):
891
+ """The fingerprint query. Interpolated, not bound: a table name and a
892
+ predicate are not bindable. The text comes from the very file whose DML is
893
+ about to run, so this opens no trust boundary that statement does not open
894
+ already — but it runs BEFORE approval, which SECURITY.md says out loud."""
895
+ return ("select count(*) n, sum(ora_hash(rowidtochar(rowid))) s, "
896
+ "min(ora_hash(rowidtochar(rowid))) lo, "
897
+ "max(ora_hash(rowidtochar(rowid))) hi from "
898
+ + target + (f" where {pred}" if pred else ""))
899
+
900
+
901
+ def sample_sql(target, pred):
902
+ return ("select * from " + target + (f" where {pred}" if pred else "")
903
+ + " fetch first :n rows only")
904
+
905
+
906
+ def measure_row_set(conn, target, pred, limit=10):
907
+ """Count, fingerprint and sample the rows the statement will touch.
908
+ Failing to measure is a refusal, never a reason to proceed: a write
909
+ approved on a row set nobody could see is what this exists to prevent.
910
+
911
+ ponytail: ROWID tracks set membership, not row contents — an in-place
912
+ update to an approved row keeps its ROWID. Hash the sampled columns too if
913
+ content drift ever needs catching."""
914
+ try:
915
+ _, agg = run_query(conn, count_sql(target, pred))
916
+ cols, rows = run_query(conn, sample_sql(target, pred),
917
+ {"n": fetch_n(limit)})
918
+ except Exception as e: # the driver's exception class is imported
919
+ sys.exit("Refused: pythia could not measure the rows this " # in main()
920
+ f"statement would touch.\n {target} — {e}\n"
921
+ "Revalidation is mandatory for UPDATE and DELETE, so a set "
922
+ "that cannot be measured cannot be approved.")
923
+ n, s, lo, hi = (list(agg[0]) + [None] * 4)[:4] if agg else (0, 0, 0, 0)
924
+ return {"target": target, "predicate": pred or "", "count": int(n or 0),
925
+ "hash": f"{s or 0}/{lo or 0}/{hi or 0}",
926
+ "columns": list(cols),
927
+ "sample": [[cell(v) for v in r] for r in rows[:limit]],
928
+ "truncated": len(rows) > limit}
929
+
930
+
931
+ def fingerprint_text(row_set):
932
+ """What the token hashes. Empty when there is no row set, so plsql_source
933
+ tokens stay byte-identical to the ones 0.8.0 printed."""
934
+ if not row_set:
935
+ return ""
936
+ return f"rows={row_set['count']} hash={row_set['hash']}"
937
+
938
+
939
+ def render_row_set(row_set, en=False, indent=" "):
940
+ """The rows an approval is about — printed identically by the preview and
941
+ by approve, because what is approved must be what was shown."""
942
+ n = row_set["count"]
943
+ where = f" where {row_set['predicate']}" if row_set["predicate"] else ""
944
+ out = [f"{indent}{paint(str(n) + ' rows', 'bold', en)} in "
945
+ f"{row_set['target']}{where}"]
946
+ if n > 1000:
947
+ out.append(indent + paint("! more rows than anyone can read one by "
948
+ "one — the count and the fingerprint are "
949
+ "the whole of what you are approving",
950
+ "yellow", en))
951
+ if row_set["sample"]:
952
+ out.append(indent + "\t".join(row_set["columns"]))
953
+ out.extend(indent + "\t".join(r) for r in row_set["sample"])
954
+ if row_set["truncated"]:
955
+ out.append(f"{indent}-- truncated at {len(row_set['sample'])} of "
956
+ f"{n} rows")
957
+ return out
958
+
959
+
801
960
  POLICY_DEFAULTS = {"plsql_source": "confirm", "data_dml": "deny",
802
961
  "structural": "deny", "grants": "deny", "session": "allow"}
803
962
 
@@ -805,6 +964,7 @@ ROLLBACK_TABLE = """\
805
964
  Is rollback real? (this table also appears in README.md and pythia-apply)
806
965
  plsql_source Yes - completely. Source is recoverable from ALL_SOURCE.
807
966
  data_dml No. After commit only Flashback Query remains, within undo retention.
967
+ Revalidation checks the row set before the write; it is not an undo.
808
968
  structural Almost never. DROP COLUMN is permanent; a dropped table may be in the Recycle Bin.
809
969
  grants Yes, but by hand.
810
970
  session Not needed."""
@@ -969,9 +1129,11 @@ def grant_path(root, token):
969
1129
  return grants_root(root) / f"{token}.json"
970
1130
 
971
1131
 
972
- def mint_grant(root, token, conn_name, now=None):
1132
+ def mint_grant(root, token, conn_name, now=None, revalidate=None):
973
1133
  """Record one developer approval. Single use, short-lived, bound to the
974
- connection the preview ran on."""
1134
+ connection the preview ran on. `revalidate` is the row-set fingerprint the
1135
+ human was shown — audit data, not a second gate: the enforcement is the
1136
+ token, which already hashes that same fingerprint."""
975
1137
  import datetime
976
1138
  now = now or datetime.datetime.now()
977
1139
  rec = {"token": token,
@@ -980,8 +1142,7 @@ def mint_grant(root, token, conn_name, now=None):
980
1142
  "expires_at": (now + datetime.timedelta(
981
1143
  minutes=GRANT_TTL_MINUTES)).isoformat(timespec="seconds"),
982
1144
  "used_at": None,
983
- # reserved: the data_dml spec re-checks the affected row set here
984
- "revalidate": None}
1145
+ "revalidate": revalidate or None}
985
1146
  d = grants_root(root)
986
1147
  d.mkdir(parents=True, exist_ok=True)
987
1148
  grant_path(root, token).write_text(json.dumps(rec, indent=2) + "\n",
@@ -1798,7 +1959,13 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1798
1959
  extra = ("no snapshot can undo it after commit"
1799
1960
  if group in ("data_dml", "structural", "grants")
1800
1961
  else "policy forbids it")
1801
- sys.exit(f"Refused: {group} is set to deny — {extra}.\n"
1962
+ note = ("\nRevalidation narrows that risk without removing it: the "
1963
+ "preview shows the rows and the count, apply refuses if that "
1964
+ "set moved, and the write is rolled back if the statement "
1965
+ "touches a different number of rows. What nothing here can do "
1966
+ "is undo a committed DELETE."
1967
+ if group == "data_dml" else "")
1968
+ sys.exit(f"Refused: {group} is set to deny — {extra}.{note}\n"
1802
1969
  f"To allow it once you have weighed that: "
1803
1970
  f"{invocation()} policy set {group} confirm")
1804
1971
  stmt = prepare_statement(file_text, group)
@@ -1837,17 +2004,23 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1837
2004
 
1838
2005
  # 1. SNAPSHOT — before anything else, unconditionally.
1839
2006
  db_source = ""
2007
+ row_set = None
1840
2008
  if group == "plsql_source":
1841
2009
  _, rows = run_query(conn, load_query("object-source.sql"),
1842
2010
  {"s": schema, "n": name, "t": otype})
1843
2011
  db_source = "".join(cell(r[0]) for r in rows)
1844
- token = apply_token(otype, name, file_text, db_source)
2012
+ elif group == "data_dml":
2013
+ # the row set IS the snapshot here: nothing can undo a committed DML,
2014
+ # so what the developer approves is the set, and the token carries it
2015
+ probe = dml_probe(stmt)
2016
+ if probe:
2017
+ row_set = measure_row_set(conn, probe[1], probe[2])
2018
+ token = apply_token(otype, name, file_text,
2019
+ db_source + fingerprint_text(row_set))
1845
2020
  if ns.yes and not human_at_the_keyboard():
1846
2021
  sys.exit(HEADLESS_YES_MSG)
1847
2022
  if ns.confirm and ns.confirm != token:
1848
- sys.exit("The confirmation token does not match: the file or the "
1849
- "database object changed since that preview. Preview again:\n"
1850
- f" {invocation()} apply {ns.file}")
2023
+ sys.exit(token_mismatch_message(ns, row_set))
1851
2024
  # The token proves the content did not move; the grant proves a human
1852
2025
  # approved it. --yes is itself that human act, at a real console.
1853
2026
  grant = None
@@ -1865,12 +2038,16 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1865
2038
  "token": token, "applied": False,
1866
2039
  "confirmed_via": ("yes" if ns.yes else
1867
2040
  "grant" if confirmed else "preview"),
2041
+ "row_set": row_set,
1868
2042
  "grant_minted_at": (grant or {}).get("minted_at"),
1869
2043
  "tty": human_at_the_keyboard(),
1870
2044
  "invalid_before": invalid_before, **(origin or {})}
1871
2045
  entry = write_journal_entry(ns.project_root, otype, name, db_source,
1872
2046
  file_text, meta)
1873
- created = not db_source.strip()
2047
+ # "created" is about an OBJECT that did not exist. A DML/DDL statement has
2048
+ # no object identity, so it is never "new" — and saying so would promise a
2049
+ # DROP-shaped undo that does not exist for those groups.
2050
+ created = not db_source.strip() and group == "plsql_source"
1874
2051
 
1875
2052
  # 2. IMPACT
1876
2053
  summary = ""
@@ -1891,19 +2068,31 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1891
2068
  print(json.dumps({"ok": True, "object": name, "type": otype,
1892
2069
  "created": created, "changed_lines": changed,
1893
2070
  "summary": summary, "warning": warn,
2071
+ "row_set": row_set,
1894
2072
  "naming_warning": style, "token": token,
1895
2073
  "journal": entry, "will_apply": confirmed}))
1896
2074
  else:
1897
2075
  en = getattr(ns, "color", False)
1898
- if created:
1899
- head = "new object"
1900
- elif changed == 0:
1901
- head = "no source change (recompile)"
2076
+ if group != "plsql_source":
2077
+ print(f"\n {paint(group, 'bold', en)} statement on {schema}")
1902
2078
  else:
1903
- head = f"{changed} lines changed"
1904
- print(f"\n {paint(f'{name} ({otype})', 'bold', en)} in {schema} — {head}")
2079
+ if created:
2080
+ head = "new object"
2081
+ elif changed == 0:
2082
+ head = "no source change (recompile)"
2083
+ else:
2084
+ head = f"{changed} lines changed"
2085
+ print(f"\n {paint(f'{name} ({otype})', 'bold', en)} in {schema} "
2086
+ f"— {head}")
1905
2087
  if summary:
1906
2088
  print(f" {summary.lstrip('- ')}")
2089
+ if row_set is not None:
2090
+ print()
2091
+ for ln in render_row_set(row_set, en):
2092
+ print(ln)
2093
+ elif group == "data_dml":
2094
+ print("\n INSERT — no rows exist beforehand, so there is no row "
2095
+ "set to revalidate.")
1907
2096
  if warn:
1908
2097
  print(f"\n {paint(warn, 'yellow', en)}")
1909
2098
  if style:
@@ -1914,7 +2103,9 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1914
2103
  print(f" {paint_diff_line(ln, en)}")
1915
2104
  print(paint(f"\n Snapshot saved: {journal_root(ns.project_root) / entry}",
1916
2105
  "dim", en))
1917
- if not confirmed:
2106
+ if not confirmed and group == "plsql_source":
2107
+ # only this group has a restore.sql worth running: the others get a
2108
+ # generated DROP that nothing would accept
1918
2109
  print(paint(" Rollback file for the current database version — "
1919
2110
  "use it if this is\n run by hand instead: "
1920
2111
  f"{journal_root(ns.project_root) / entry / 'restore.sql'}",
@@ -1940,6 +2131,17 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1940
2131
  "'IDENTIFIERS:ALL, STATEMENTS:ALL'")
1941
2132
  with conn.cursor() as cur:
1942
2133
  cur.execute(stmt)
2134
+ affected = cur.rowcount if group == "data_dml" else None
2135
+ if group == "data_dml":
2136
+ # the last-millisecond check: the probe ran before this statement, and
2137
+ # READ COMMITTED lets the world move in between
2138
+ if row_set is not None and affected != row_set["count"]:
2139
+ conn.rollback()
2140
+ sys.exit(f"Rolled back: the statement touched {affected} rows, but "
2141
+ f"{row_set['count']} were approved.\nNothing was "
2142
+ "committed — the rows moved between the check and the "
2143
+ "write. Preview again.")
2144
+ conn.commit() # DDL commits itself; DML never did until now
1943
2145
  if grant is not None:
1944
2146
  spend_grant(ns.project_root, token) # one approval, one write
1945
2147
 
@@ -1968,14 +2170,20 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1968
2170
  print(json.dumps({"ok": ok, "applied": True, "object": name,
1969
2171
  "type": otype, "errors": [list(r) for r in own_errors],
1970
2172
  "newly_invalid": [list(x) for x in broke],
1971
- "restore": f"{invocation()} journal restore {entry}",
2173
+ "rows": affected,
2174
+ "restore": (f"{invocation()} journal restore {entry}"
2175
+ if group == "plsql_source" else None),
2176
+ "no_undo": group != "plsql_source",
1972
2177
  "restore_is_drop": created,
1973
2178
  "restore_blocked_by_policy": (
1974
2179
  created and undo_group_action(ns) == "deny"),
1975
2180
  "exit": 0 if ok else 3}))
1976
2181
  else:
1977
2182
  en = getattr(ns, "color", False)
1978
- if ok:
2183
+ if ok and group == "data_dml":
2184
+ print(paint(f"\n Applied — {affected} rows changed and "
2185
+ "committed.", "green", en))
2186
+ elif ok:
1979
2187
  print(paint(f"\n Applied {name} ({otype}).", "green", en))
1980
2188
  print(paint(" Compiled clean. No new INVALID objects.", "green", en))
1981
2189
  else:
@@ -1992,21 +2200,28 @@ def run_apply(conn, schema, ns, file_text, origin=None):
1992
2200
  print(f" {len(broke)} objects were VALID before and are INVALID now:")
1993
2201
  for n2, t2 in broke:
1994
2202
  print(paint(f" {n2} ({t2})", "red", en))
1995
- if created:
1996
- print("\n To undo — note: undo means DROPPING it "
1997
- "(it did not exist before):")
2203
+ if group != "plsql_source":
2204
+ # no snapshot was taken, so there is nothing to restore — say that
2205
+ # instead of printing a restore command that would be refused
2206
+ print(paint("\n There is no undo: the journal kept the statement "
2207
+ "that ran, not a way back.", "yellow", en))
1998
2208
  else:
1999
- print("\n To undo:")
2000
- print(" " + paint(f"{invocation()} journal restore {entry}",
2001
- "cyan", en))
2002
- if created and undo_group_action(ns) == "deny":
2003
- # the honest half: that restore is a DROP, DROP is structural,
2004
- # and structural is deny — so the line above would be refused
2005
- print(paint(" ...but that restore is a DROP, and structural is "
2006
- "set to deny, so it will be refused.", "yellow", en))
2007
- print(paint(" The developer decides: "
2008
- f"{invocation()} policy set structural confirm",
2009
- "yellow", en))
2209
+ if created:
2210
+ print("\n To undo — note: undo means DROPPING it "
2211
+ "(it did not exist before):")
2212
+ else:
2213
+ print("\n To undo:")
2214
+ print(" " + paint(f"{invocation()} journal restore {entry}",
2215
+ "cyan", en))
2216
+ if created and undo_group_action(ns) == "deny":
2217
+ # the honest half: that restore is a DROP, DROP is structural,
2218
+ # and structural is deny — so the line above would be refused
2219
+ print(paint(" ...but that restore is a DROP, and structural "
2220
+ "is set to deny, so it will be refused.",
2221
+ "yellow", en))
2222
+ print(paint(" The developer decides: "
2223
+ f"{invocation()} policy set structural confirm",
2224
+ "yellow", en))
2010
2225
  return 0 if ok else 3
2011
2226
 
2012
2227
 
@@ -2033,6 +2248,32 @@ def find_preview_by_token(root, token):
2033
2248
  return None, None
2034
2249
 
2035
2250
 
2251
+ def token_mismatch_message(ns, row_set):
2252
+ """Why the token no longer matches. For DML the interesting answer is
2253
+ almost always the rows, and the preview's own journal entry still holds
2254
+ what they were — so say which, instead of a generic 'something moved'."""
2255
+ base = ("The confirmation token does not match: the file or the "
2256
+ "database object changed since that preview. Preview again:\n"
2257
+ f" {invocation()} apply {ns.file}")
2258
+ if not row_set:
2259
+ return base
2260
+ _, meta = find_preview_by_token(ns.project_root,
2261
+ str(ns.confirm).strip().lower())
2262
+ then = (meta or {}).get("row_set") or {}
2263
+ if not then or fingerprint_text(then) == fingerprint_text(row_set):
2264
+ return base # the file moved, not the rows
2265
+ if then["count"] != row_set["count"]:
2266
+ moved = (f"{then['count']} rows when you approved it, "
2267
+ f"{row_set['count']} now")
2268
+ else:
2269
+ moved = (f"still {row_set['count']} rows, but not the same ones — the "
2270
+ "row fingerprint changed")
2271
+ return ("Refused: the row set moved — " + moved + ".\n"
2272
+ "Nothing was written. The approval covered the rows as they were.\n"
2273
+ "Preview again to see them as they are now:\n"
2274
+ f" {invocation()} apply {ns.file}")
2275
+
2276
+
2036
2277
  def cmd_approve(conn, schema, ns):
2037
2278
  """The developer's half of the gate: one human act, at a real console,
2038
2279
  that lets the agent's next `apply --confirm` through. Touches no
@@ -2052,12 +2293,15 @@ def cmd_approve(conn, schema, ns):
2052
2293
  f" {invocation()} apply <file>")
2053
2294
  conn_name = meta.get("connection") or ""
2054
2295
  prune_expired_grants(ns.project_root)
2055
- rec = mint_grant(ns.project_root, token, conn_name)
2296
+ row_set = meta.get("row_set")
2297
+ rec = mint_grant(ns.project_root, token, conn_name,
2298
+ revalidate=fingerprint_text(row_set))
2056
2299
  obj, otype = meta.get("object", "?"), meta.get("type", "?")
2057
2300
  group = meta.get("group", "")
2058
2301
  if ns.json:
2059
2302
  print(json.dumps({"ok": True, "token": token, "conn": conn_name,
2060
2303
  "object": obj, "type": otype, "group": group,
2304
+ "rows": (row_set or {}).get("count"),
2061
2305
  "entry": entry, "minted_at": rec["minted_at"],
2062
2306
  "expires_at": rec["expires_at"],
2063
2307
  "ttl_minutes": GRANT_TTL_MINUTES}))
@@ -2071,6 +2315,12 @@ def cmd_approve(conn, schema, ns):
2071
2315
  print(f" Approving a {group} statement on {meta.get('schema', '?')}:")
2072
2316
  for ln in stmt.splitlines():
2073
2317
  print(f" {ln}")
2318
+ if row_set:
2319
+ # the rows themselves, exactly as the preview showed them: what is
2320
+ # approved has to be what was seen
2321
+ print()
2322
+ for ln in render_row_set(row_set, en, indent=" "):
2323
+ print(ln)
2074
2324
  print(paint("\n There is no snapshot for this group — after commit "
2075
2325
  "it cannot be undone from the journal.", "yellow", en))
2076
2326
  else:
@@ -2090,6 +2340,18 @@ def run_restore(conn, schema, ns):
2090
2340
  """Restore is itself a write: feed the saved statement back through the
2091
2341
  same six steps. There is no second write path and no silent restore."""
2092
2342
  e = read_journal_entry(ns.project_root, ns.id)
2343
+ group = e["meta"].get("group", "plsql_source")
2344
+ if group != "plsql_source":
2345
+ # render_restore() writes a DROP for every group that has no snapshot,
2346
+ # which classifies as structural and dies with a message about the
2347
+ # wrong thing. Say what is actually true, once, for all of them.
2348
+ tail = ("Flashback Query within undo retention is the only route left, "
2349
+ "and it is a DBA's job."
2350
+ if group == "data_dml" else
2351
+ "Reversing it means writing the opposite statement by hand.")
2352
+ sys.exit(f"Refused: entry {ns.id} is a {group} statement, and the "
2353
+ "journal holds no undo for it — only the statement that "
2354
+ f"ran.\n{tail}")
2093
2355
  print(f"Restoring from {ns.id} — this is itself a write and goes through "
2094
2356
  "the full six steps.", file=sys.stderr)
2095
2357
  return run_apply(conn, schema, ns, e["restore"], origin={"restored_from": ns.id})
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pythia-plsql
3
- Version: 0.8.0
3
+ Version: 0.9.0
4
4
  Summary: PL/SQL development for AI agents on Oracle Database - expert data-dictionary queries, impact analysis, and a snapshot-verified write path with honest rollback.
5
5
  License: MIT
6
6
  Project-URL: Homepage, https://github.com/thaildhe172591/pythia
@@ -211,7 +211,7 @@ Per-group write policy, `.pythia/policy.json` (defaults shown):
211
211
  | Group | Default | Is rollback real? |
212
212
  |---|---|---|
213
213
  | `plsql_source` | `confirm` | **Yes — completely.** Source is recoverable from `ALL_SOURCE`. |
214
- | `data_dml` | `deny` | **No.** After commit only Flashback Query remains, within undo retention. |
214
+ | `data_dml` | `deny` | **No.** After commit only Flashback Query remains, within undo retention. Revalidation checks the row set *before* the write; it is not an undo. |
215
215
  | `structural` | `deny` | **Almost never.** `DROP COLUMN` is permanent; a dropped table may be in the Recycle Bin. |
216
216
  | `grants` | `deny` | Yes, but by hand. |
217
217
  | `session` | `allow` | Not needed. |
@@ -9,9 +9,9 @@ description: Use when a PL/SQL change is ready to reach the database - applying
9
9
 
10
10
  **Phase:** Ask → Do — the preview is relayed and approved before the one write door opens
11
11
 
12
- DDL in Oracle commits itself. There is no transaction to roll back — the
13
- snapshot pythia takes before writing is the only undo there is, and this skill
14
- exists so that net is always used, and used honestly.
12
+ DDL in Oracle commits itself, so the snapshot pythia takes before writing is
13
+ the only undo there is — and for DML not even that. This skill exists so that
14
+ net is always used, and used honestly.
15
15
 
16
16
  ## The Iron Law
17
17
 
@@ -41,9 +41,8 @@ message.
41
41
  Sometimes the developer runs the file themselves — a DBA executes it, a
42
42
  release process owns it, or policy denies the group. The preview still ran, so
43
43
  a rollback file for the live version exists at
44
- `.pythia/journal/<entry>/restore.sql`. Never let a change leave your hands for
45
- manual execution without naming that path alongside the .sql file (`pythia
46
- history <OBJECT>` lists every captured version).
44
+ `.pythia/journal/<entry>/restore.sql` (`pythia history <OBJECT>` lists every
45
+ captured version). Never let a change leave your hands without naming it.
47
46
 
48
47
  ## The Workflow
49
48
 
@@ -92,10 +91,9 @@ means DROP — policy refuses it under `structural: deny`, and that is correct.
92
91
 
93
92
  ## Batch mode
94
93
 
95
- `--yes` skips the pause, not the preview — and it is the developer's flag: at
96
- their terminal it *is* the approval, so no separate approve is needed. A
97
- frustrated "stop asking" grants it for the task at hand, not from now on:
98
- confirm the scope once and default to this-batch-only. It never covers restores.
94
+ `--yes` skips the pause, not the preview — it is the developer's flag, and at
95
+ their terminal it *is* the approval. A frustrated "stop asking" grants it for
96
+ the task at hand, not from now on; it never covers restores.
99
97
 
100
98
  - **Stop at the first exit 3.** Never keep applying onto a broken state.
101
99
  - Afterwards report: one line per success, full detail (errors, newly INVALID,
@@ -114,7 +112,7 @@ information for the developer, not an obstacle to route around.
114
112
  | Group | Is rollback real? |
115
113
  |---|---|
116
114
  | `plsql_source` | **Yes — completely.** The source is recoverable from `ALL_SOURCE`. |
117
- | `data_dml` | **No.** After commit only Flashback Query remains, and only within undo retention. |
115
+ | `data_dml` | **No.** After commit only Flashback Query remains, and only within undo retention. Revalidation checks the row set *before* the write; it is not an undo. |
118
116
  | `structural` | **Almost never.** `DROP COLUMN` is permanent; a dropped table may be in the Recycle Bin. |
119
117
  | `grants` | Yes, but by hand. |
120
118
  | `session` | Not needed. |
@@ -122,6 +120,10 @@ information for the developer, not an obstacle to route around.
122
120
  Never promise "we can always roll back" — true only for the first row, and
123
121
  saying it generally misleads the developer when the stakes are highest.
124
122
 
123
+ `data_dml` is revalidated on its **rows**: preview and `approve` show the
124
+ affected count and up to ten rows, and `--confirm` refuses if that set moved.
125
+ `MERGE`, and anything unmeasurable, is refused rather than guessed at.
126
+
125
127
  ## Red Flags — STOP if you catch yourself thinking
126
128
 
127
129
  | Thought | Reality |
@@ -133,9 +135,7 @@ saying it generally misleads the developer when the stakes are highest.
133
135
  | "apply refused it; run-sql will take it" | The refusal is the product working. Relay it. |
134
136
  | "I'll restore quietly to clean up my mistake" | Restores are writes. Same gate, same visibility. |
135
137
  | "`$?` said 0 after I piped to tail" | That was tail's 0. Read pythia's own words, or its unpiped code. |
136
- | "The dev said 'stop asking' once" | That covered that task, not forever. Re-confirm scope on the next one. |
137
138
  | "I'll run approve myself to unblock this" | The developer's console act. It refuses you, and routing around it is what this gate exists to stop. |
138
- | "No approval on file — I'll retry until it works" | Retrying does not create approval. Relay the line, wait for a human. |
139
139
 
140
140
  ## When NOT to use this skill
141
141
 
@@ -70,6 +70,43 @@ def test_prepare_statement_terminators():
70
70
  "truncate table a; truncate table b;", "structural"), "one statement")
71
71
 
72
72
 
73
+ def test_mask_literals_keeps_length_and_blanks_text():
74
+ m = pythia.mask_literals
75
+ src = "update t set n = 'x where y' where id = 1"
76
+ assert len(m(src)) == len(src)
77
+ assert "where y" not in m(src)
78
+ assert m("delete from t -- where later" + chr(10)
79
+ + "where id = 1").count("where") == 1
80
+ assert m("delete from t /* where */ where id = 1").count("where") == 1
81
+ assert "it" not in m("update t set n = 'it''s' where id = 1")
82
+
83
+
84
+ def test_dml_probe_reads_target_and_predicate():
85
+ assert pythia.dml_probe("delete from t_order where status = 'DRAFT'") == (
86
+ "delete", "t_order", "status = 'DRAFT'")
87
+ assert pythia.dml_probe("delete app.t_order t where t.id = 7") == (
88
+ "delete", "app.t_order t", "t.id = 7")
89
+ assert pythia.dml_probe("update t_order t set status = 'X' "
90
+ "where t.id = 7") == (
91
+ "update", "t_order t", "t.id = 7")
92
+ assert pythia.dml_probe("delete from t_order") == ("delete", "t_order", None)
93
+ assert pythia.dml_probe("insert into t values (1)") is None
94
+
95
+
96
+ def test_dml_probe_refuses_what_it_cannot_measure():
97
+ expect_exit(lambda: pythia.dml_probe(
98
+ "merge into t using d on (1=1) when matched then update set x=1"),
99
+ "merge", "join")
100
+ expect_exit(lambda: pythia.dml_probe(
101
+ "delete from t where id in (select id from u)"), "subquery")
102
+ expect_exit(lambda: pythia.dml_probe(
103
+ "update t set a = 1 where id in (1) and x = 2 where y = 3"),
104
+ "more than one where")
105
+ expect_exit(lambda: pythia.dml_probe(
106
+ "update t set n = q'{x}' where id = 1"), "quoted")
107
+ expect_exit(lambda: pythia.dml_probe("update t where id = 1"), "set clause")
108
+
109
+
73
110
  def test_apply_token_is_content_bound():
74
111
  t1 = pythia.apply_token("PACKAGE BODY", "PKG_ORDER", "create...", "old src")
75
112
  assert len(t1) == 6 and all(c in "0123456789abcdef" for c in t1)
@@ -135,9 +172,10 @@ def test_newly_invalid_and_diff():
135
172
 
136
173
 
137
174
  class FakeCursor:
138
- def __init__(self, script, executed):
175
+ def __init__(self, script, executed, rowcount=0):
139
176
  self.script, self.executed = script, executed
140
177
  self.description, self._rows = None, []
178
+ self.rowcount = rowcount
141
179
 
142
180
  def execute(self, sql, binds=None):
143
181
  self.executed.append((sql, dict(binds or {})))
@@ -166,11 +204,19 @@ class FakeCursor:
166
204
 
167
205
 
168
206
  class FakeConn:
169
- def __init__(self, script):
207
+ def __init__(self, script, rowcount=0):
170
208
  self.script, self.executed = script, []
209
+ self.rowcount = rowcount
210
+ self.commits, self.rollbacks = 0, 0
171
211
 
172
212
  def cursor(self):
173
- return FakeCursor(self.script, self.executed)
213
+ return FakeCursor(self.script, self.executed, self.rowcount)
214
+
215
+ def commit(self):
216
+ self.commits += 1
217
+
218
+ def rollback(self):
219
+ self.rollbacks += 1
174
220
 
175
221
 
176
222
  OLD_SRC = "PACKAGE BODY pkg_order AS\n old line;\nEND;\n"
@@ -214,6 +260,166 @@ def wrote_ddl(conn):
214
260
  return [s for s, _ in conn.executed if s.lstrip().lower().startswith("create")]
215
261
 
216
262
 
263
+ DML_FILE = "delete from t_order where status = 'DRAFT'\n"
264
+
265
+
266
+ def dml_script(count=2, hashes=(11, 1, 9), rows=((7, "DRAFT"), (8, "DRAFT"))):
267
+ s = base_script()
268
+ s["ora_hash"] = (("N", "S", "LO", "HI"), [(count,) + tuple(hashes)])
269
+ s["select * from"] = (("ID", "STATUS"), list(rows))
270
+ return s
271
+
272
+
273
+ def dml_ns(root, **kw):
274
+ ns = apply_ns(root, **kw)
275
+ ns.file = "d.sql"
276
+ return ns
277
+
278
+
279
+ def allow_dml(root):
280
+ d = pathlib.Path(root) / ".pythia"
281
+ d.mkdir(exist_ok=True)
282
+ (d / "policy.json").write_text(json.dumps({"data_dml": "confirm"}),
283
+ encoding="utf-8")
284
+
285
+
286
+ def wrote_dml(conn):
287
+ return [s for s, _ in conn.executed
288
+ if s.lstrip().lower().startswith(("delete", "update", "insert"))]
289
+
290
+
291
+ def test_dml_preview_measures_the_row_set_and_writes_nothing():
292
+ with tempfile.TemporaryDirectory() as td:
293
+ allow_dml(td)
294
+ conn = FakeConn(dml_script())
295
+ code = pythia.run_apply(conn, "APP", dml_ns(td), DML_FILE)
296
+ assert code == 0
297
+ assert wrote_dml(conn) == [] and conn.commits == 0
298
+ entry = pythia.list_journal_entries(td)[0]
299
+ rs = pythia.read_journal_entry(td, entry)["meta"]["row_set"]
300
+ assert rs["count"] == 2 and rs["target"] == "t_order"
301
+ assert rs["predicate"] == "status = 'DRAFT'"
302
+ assert rs["sample"] == [["7", "DRAFT"], ["8", "DRAFT"]]
303
+
304
+
305
+ def test_the_row_set_moves_the_token():
306
+ with tempfile.TemporaryDirectory() as td:
307
+ allow_dml(td)
308
+ pythia.run_apply(FakeConn(dml_script(count=2)), "APP",
309
+ dml_ns(td), DML_FILE)
310
+ first = pythia.read_journal_entry(
311
+ td, pythia.list_journal_entries(td)[0])["meta"]["token"]
312
+ pythia.run_apply(FakeConn(dml_script(count=3)), "APP",
313
+ dml_ns(td), DML_FILE)
314
+ second = pythia.read_journal_entry(
315
+ td, pythia.list_journal_entries(td)[0])["meta"]["token"]
316
+ assert first != second, "the fingerprint must reach the token"
317
+
318
+
319
+ def approved(td, conn):
320
+ """Preview, then approve — the two-step gate, in test form. Returns the
321
+ token the agent would pass to --confirm."""
322
+ pythia.run_apply(conn, "APP", dml_ns(td), DML_FILE)
323
+ entry = pythia.list_journal_entries(td)[0]
324
+ token = pythia.read_journal_entry(td, entry)["meta"]["token"]
325
+ pythia.mint_grant(td, token, "DEV")
326
+ return token
327
+
328
+
329
+ def test_dml_commits_once_when_the_rowcount_matches():
330
+ with tempfile.TemporaryDirectory() as td:
331
+ allow_dml(td)
332
+ token = approved(td, FakeConn(dml_script(count=2)))
333
+ conn = FakeConn(dml_script(count=2), rowcount=2)
334
+ code = pythia.run_apply(conn, "APP", dml_ns(td, confirm=token), DML_FILE)
335
+ assert code == 0
336
+ assert conn.commits == 1 and conn.rollbacks == 0
337
+ assert wrote_dml(conn)
338
+
339
+
340
+ def test_dml_rolls_back_when_the_rowcount_diverges():
341
+ with tempfile.TemporaryDirectory() as td:
342
+ allow_dml(td)
343
+ token = approved(td, FakeConn(dml_script(count=2)))
344
+ conn = FakeConn(dml_script(count=2), rowcount=5)
345
+ expect_exit(lambda: pythia.run_apply(
346
+ conn, "APP", dml_ns(td, confirm=token), DML_FILE),
347
+ "rolled back", "5", "2")
348
+ assert conn.rollbacks == 1 and conn.commits == 0
349
+
350
+
351
+ def test_a_moved_row_set_refuses_the_confirm():
352
+ with tempfile.TemporaryDirectory() as td:
353
+ allow_dml(td)
354
+ token = approved(td, FakeConn(dml_script(count=2)))
355
+ conn = FakeConn(dml_script(count=3), rowcount=3)
356
+ expect_exit(lambda: pythia.run_apply(
357
+ conn, "APP", dml_ns(td, confirm=token), DML_FILE),
358
+ "row set moved", "2 rows", "3")
359
+ assert conn.commits == 0 and wrote_dml(conn) == []
360
+
361
+
362
+ def test_dml_preview_promises_no_object_and_no_rollback_file():
363
+ """A DML statement is not a "new object", and the restore.sql generated
364
+ for it is a DROP nothing would accept — the preview must offer neither."""
365
+ import contextlib
366
+ import io
367
+ with tempfile.TemporaryDirectory() as td:
368
+ allow_dml(td)
369
+ out = io.StringIO()
370
+ with contextlib.redirect_stdout(out):
371
+ pythia.run_apply(FakeConn(dml_script()), "APP", dml_ns(td), DML_FILE)
372
+ text = out.getvalue()
373
+ assert "data_dml statement on APP" in text
374
+ assert "new object" not in text and "restore.sql" not in text
375
+
376
+
377
+ def test_restore_refuses_an_entry_that_has_no_undo():
378
+ with tempfile.TemporaryDirectory() as td:
379
+ entry = pythia.write_journal_entry(
380
+ td, "DATA_DML", "STATEMENT", "",
381
+ "delete from t_order where id = 7",
382
+ {"connection": "DEV", "group": "data_dml", "applied": True})
383
+ ns = dml_ns(td, id=entry)
384
+ expect_exit(lambda: pythia.run_restore(FakeConn(base_script()),
385
+ "APP", ns),
386
+ "no undo", "flashback")
387
+
388
+
389
+ def test_deny_message_explains_what_revalidation_buys():
390
+ with tempfile.TemporaryDirectory() as td:
391
+ expect_exit(lambda: pythia.run_apply(FakeConn(dml_script()), "APP",
392
+ dml_ns(td), DML_FILE),
393
+ "deny", "revalidation", "policy set data_dml confirm")
394
+
395
+
396
+ def test_approve_records_the_row_set_it_showed():
397
+ with tempfile.TemporaryDirectory() as td:
398
+ allow_dml(td)
399
+ pythia.run_apply(FakeConn(dml_script(count=2)), "APP",
400
+ dml_ns(td), DML_FILE)
401
+ meta = pythia.read_journal_entry(
402
+ td, pythia.list_journal_entries(td)[0])["meta"]
403
+ rec = pythia.mint_grant(
404
+ td, meta["token"], "DEV",
405
+ revalidate=pythia.fingerprint_text(meta["row_set"]))
406
+ assert rec["revalidate"].startswith("rows=2 hash=")
407
+ assert pythia.read_grant(td, meta["token"])["revalidate"] \
408
+ == rec["revalidate"]
409
+
410
+
411
+ def test_insert_needs_no_row_set():
412
+ with tempfile.TemporaryDirectory() as td:
413
+ allow_dml(td)
414
+ conn = FakeConn(base_script())
415
+ code = pythia.run_apply(conn, "APP", dml_ns(td),
416
+ "insert into t_order (id) values (1)\n")
417
+ assert code == 0
418
+ assert not [s for s, _ in conn.executed if "ora_hash" in s.lower()]
419
+ entry = pythia.list_journal_entries(td)[0]
420
+ assert pythia.read_journal_entry(td, entry)["meta"]["row_set"] is None
421
+
422
+
217
423
  def test_apply_preview_writes_nothing_and_gives_token():
218
424
  with tempfile.TemporaryDirectory() as td:
219
425
  conn = FakeConn(base_script())
@@ -98,7 +98,8 @@ def test_apply_skill_keeps_the_honest_rollback_table():
98
98
  return
99
99
  text = path.read_text(encoding="utf-8")
100
100
  for needle in ("Yes — completely", "Flashback Query", "Recycle Bin",
101
- "Almost never", "exit 3", "run-sql"):
101
+ "Almost never", "exit 3", "run-sql",
102
+ "revalidat"): # 0.9.0: the row-set gate
102
103
  assert needle in text, f"pythia-apply: missing {needle!r}"
103
104
 
104
105
 
File without changes
File without changes