Skip to content

Commit e3228ae

Browse files
committed
release: v1.5.0 — architecture state + migration preview + policy-gated schema drops
- Add scoped Architecture Diff (+_hist scope expansion) with stable state fingerprinting - Add Migration Plan Preview and Shadow Compare (intent vs. materialization DDL) - Add policy-gated DROP COLUMN support (base + optional _hist via env flags) - Improve drift/type normalization across dialects and clarify CLI notices for destructive DDL - Update documentation (architecture overview, schema evolution, historization policy) and README blurb
1 parent d4f0628 commit e3228ae

29 files changed

Lines changed: 2388 additions & 14 deletions

.elevata/state/architecture_state.json

Whitespace-only changes.

.env.example

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ ELEVATA_SQL_DIALECT=duckdb
2828
ELEVATA_TARGET_SYSTEM=dwh
2929

3030
ELEVATA_ALLOW_AUTO_DROP_COLUMNS=false
31+
ELEVATA_ALLOW_AUTO_DROP_HIST_COLUMNS=false
3132
ELEVATA_ALLOW_TYPE_ALTER=false
3233

3334
# --- Pepper value for hash keys ---

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
# elevata
1212
core/.artifacts/
13+
core/.elevata/
1314

1415
# Python
1516
__pycache__/

CHANGELOG.md

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,48 @@ TBD
1919

2020
---
2121

22+
## [1.5.0] - 2026-04-24
23+
24+
This release strengthens elevata’s Architecture Runtime by making architecture drift visible,
25+
scoping changes to the executed dataset set, and introducing policy-gated destructive schema operations.
26+
27+
The focus is deterministic, explainable schema evolution across supported warehouses.
28+
29+
---
30+
31+
### ✨ Added
32+
33+
#### Architecture State & Diff (scoped)
34+
35+
- Architecture state persistence with stable fingerprinting
36+
- Scoped architecture diff for the current execution order (includes related `_hist` targets)
37+
- Migration plan preview for renames, adds, drops and type evolution signals
38+
- Shadow compare that cross-checks migration intent vs. planned materialization DDL
39+
40+
#### Policy-gated destructive schema operations
41+
42+
- Base-table `DROP COLUMN` support gated by `ELEVATA_ALLOW_AUTO_DROP_COLUMNS`
43+
- Optional `_hist` physical drops gated by `ELEVATA_ALLOW_AUTO_DROP_HIST_COLUMNS`
44+
- Minimal CLI notice when destructive DDL is planned/executed (prevents “silent cleanup” surprises)
45+
46+
---
47+
48+
### 🔄 Improved
49+
50+
- Clearer CLI output for architecture drift and migration intent
51+
- More robust cross-dialect type normalization and equivalence handling
52+
- Improved determinism for drift detection across warehouse-specific type representations
53+
54+
---
55+
56+
### 🛠️ Fixed
57+
58+
- Multiple edge cases in schema drift planning around rename/add/drop interactions
59+
- Improved correctness for historization-related schema sync scenarios
60+
- Miscellaneous stability improvements in execution + materialization tooling
61+
62+
---
63+
2264
## [1.4.3] - 2026-04-04
2365

2466
This patch release restores the correct logo asset reference in elevata's UI.

README.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ validated before execution.
5252

5353

5454
<p align="center">
55-
<img src="https://raw.githubusercontent.com/elevata-labs/elevata/main/docs/elevata_v1_4_0.png" alt="elevata UI preview" width="700"/>
55+
<img src="https://raw.githubusercontent.com/elevata-labs/elevata/main/docs/elevata_v1_5_0.png" alt="elevata UI preview" width="700"/>
5656
<br/>
5757
<em>Dataset detail view with lineage, metadata, and dialect-aware SQL previews</em>
5858
</p>
@@ -114,6 +114,8 @@ schema evolution, and structured load logging.
114114

115115
Behavior is deterministic and observable.
116116

117+
Schema drift is reconciled via metadata-driven materialization planning (renames, adds; destructive changes are policy-gated).
118+
117119
---
118120

119121
## 📐 Query Builder

core/elevata_site/settings.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@
4343
BASE_DIR = Path(__file__).resolve().parent.parent
4444
load_dotenv(find_dotenv(filename=".env", raise_error_if_not_found=False))
4545

46-
ELEVATA_VERSION = "1.4.3"
46+
ELEVATA_VERSION = "1.5.0"
4747

4848
ELEVATA_PROFILES_PATH = os.getenv("ELEVATA_PROFILES_PATH", str((BASE_DIR.parent / "config" / "elevata_profiles.yaml")))
4949

core/generic.py

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1415,12 +1415,135 @@ def row_delete(self, request, pk):
14151415
return htmx_oob_warning(
14161416
"Cannot delete: downstream datasets depend on this (see Lineage). Remove downstream dependencies first."
14171417
)
1418+
1419+
def _maybe_allow_rawcore_column_delete_via_hist_orphaning(*, col, protected_objects, actor) -> bool:
1420+
"""
1421+
Allow deleting a non-system-managed rawcore base TargetColumn even if it is PROTECTed
1422+
by system-generated TargetColumnInput links to its *_hist mirror column.
1423+
1424+
Behavior:
1425+
- deactivate the linked *_hist column(s) (do NOT delete)
1426+
- delete only the specific TargetColumnInput rows that caused the PROTECT
1427+
- then the caller can retry col.delete()
1428+
1429+
Safety:
1430+
- only applies if ALL protected_objects are TargetColumnInput rows
1431+
pointing from *_hist -> this base column.
1432+
"""
1433+
try:
1434+
if col is None or col.__class__.__name__ != "TargetColumn":
1435+
return False
1436+
1437+
# Only user/business columns should be deletable.
1438+
if getattr(col, "is_system_managed", False) or getattr(col, "system_role", None):
1439+
return False
1440+
1441+
td = getattr(col, "target_dataset", None)
1442+
if td is None:
1443+
return False
1444+
schema = getattr(td, "target_schema", None)
1445+
if getattr(schema, "short_name", None) != "rawcore":
1446+
return False
1447+
1448+
ds_name = getattr(td, "target_dataset_name", "") or ""
1449+
if not isinstance(ds_name, str) or ds_name.endswith("_hist"):
1450+
return False
1451+
1452+
# If the dataset does not historize, there should be no hist-mirror dependencies.
1453+
if not getattr(td, "historize", False):
1454+
return False
1455+
1456+
protected = list(protected_objects or [])
1457+
if not protected:
1458+
return False
1459+
1460+
# Must be ONLY TargetColumnInput references (otherwise: real dependency, keep blocking).
1461+
if any(getattr(o, "__class__", None).__name__ != "TargetColumnInput" for o in protected):
1462+
return False
1463+
1464+
ids = [getattr(o, "pk", None) for o in protected if getattr(o, "pk", None)]
1465+
if not ids:
1466+
return False
1467+
1468+
TargetDataset = apps.get_model("metadata", "TargetDataset")
1469+
TargetColumn = apps.get_model("metadata", "TargetColumn")
1470+
TargetColumnInput = apps.get_model("metadata", "TargetColumnInput")
1471+
1472+
# Resolve hist dataset (prefer lineage_key, fallback to naming convention).
1473+
hist_td = None
1474+
lk = getattr(td, "lineage_key", None)
1475+
if lk:
1476+
hist_td = (
1477+
TargetDataset.objects
1478+
.filter(
1479+
target_schema=td.target_schema,
1480+
lineage_key=lk,
1481+
target_dataset_name__endswith="_hist",
1482+
)
1483+
.first()
1484+
)
1485+
if hist_td is None:
1486+
hist_td = (
1487+
TargetDataset.objects
1488+
.filter(
1489+
target_schema=td.target_schema,
1490+
target_dataset_name=f"{ds_name}_hist",
1491+
)
1492+
.first()
1493+
)
1494+
if hist_td is None:
1495+
return False
1496+
1497+
# Ensure the ProtectedError is ONLY about those hist-mirror inputs.
1498+
tci_qs = TargetColumnInput.objects.filter(pk__in=ids, upstream_target_column=col)
1499+
if tci_qs.count() != len(ids):
1500+
return False
1501+
tci_qs = tci_qs.filter(target_column__target_dataset=hist_td)
1502+
if tci_qs.count() != len(ids):
1503+
return False
1504+
1505+
hist_col_ids = list(tci_qs.values_list("target_column_id", flat=True))
1506+
if not hist_col_ids:
1507+
return False
1508+
1509+
# Deactivate hist columns (do not delete).
1510+
update_payload = {"active": False}
1511+
if hasattr(TargetColumn, "is_system_managed"):
1512+
update_payload["is_system_managed"] = True
1513+
if actor is not None and hasattr(TargetColumn, "updated_by_id"):
1514+
update_payload["updated_by"] = actor
1515+
TargetColumn.objects.filter(pk__in=hist_col_ids).update(**update_payload)
1516+
1517+
# Remove only the exact inputs that blocked deletion.
1518+
tci_qs.delete()
1519+
return True
1520+
except Exception:
1521+
return False
14181522

14191523
try:
14201524
obj.delete()
14211525
html = f'<tr id="row-{obj_id}" hx-swap-oob="delete"></tr>'
14221526
return HttpResponse(html, status=200)
14231527
except ProtectedError as e:
1528+
# Special case: allow deleting a user-managed rawcore base column even if
1529+
# it is PROTECTed by system-generated *_hist mirror inputs.
1530+
try:
1531+
if obj.__class__.__name__ == "TargetColumn":
1532+
actor = get_current_user() or getattr(request, "user", None)
1533+
if not getattr(actor, "is_authenticated", False):
1534+
actor = None
1535+
if _maybe_allow_rawcore_column_delete_via_hist_orphaning(
1536+
col=obj,
1537+
protected_objects=getattr(e, "protected_objects", None),
1538+
actor=actor,
1539+
):
1540+
# Retry after detaching the hist-mirror dependencies.
1541+
obj.delete()
1542+
html = f'<tr id="row-{obj_id}" hx-swap-oob="delete"></tr>'
1543+
return HttpResponse(html, status=200)
1544+
except Exception:
1545+
pass
1546+
14241547
# HTMX: show a helpful feedback instead of silently failing
14251548
msg = "Cannot delete: this record is still referenced."
14261549
try:

core/metadata/architecture/__init__.py

Whitespace-only changes.

0 commit comments

Comments
 (0)