Cluster ERP writes a history row for every change to every business entity, automatically, in the same transaction as the change. This page explains the capture in core/change_history.py, the value redaction in core/sensitive_fields.py, the legacy audit_log table in core/audit.py with its backfill in core/audit_backfill.py, and the migration evidence rows in core/migration_history.py. The screens that show the history belong to the logs module; see logs.
In short¶
- One SQLAlchemy
before_flushlistener onSessioncaptures every insert, update, and delete of every object whose class derives fromBase(src/cluster_erp/core/change_history.py:318-351,src/cluster_erp/core/change_history.py:262-270). - History rows go to
core_change_historyin the same transaction as the change (src/cluster_erp/core/change_history.py:12-14,src/cluster_erp/core/change_history.py:349-350). - The actor and correlation id come from the request context, not from function arguments (
src/cluster_erp/core/change_history.py:320-321). - Credential, special-category, and identifier columns keep their change record but lose their values (
src/cluster_erp/core/change_history.py:166-169,src/cluster_erp/core/sensitive_fields.py:61-82). - The module is imported by
server/app.pyso that the listener is registered before any session flushes (src/cluster_erp/server/app.py:33-35).
How it works¶
The capture¶
The flowchart shows what the listener does on each flush.
flowchart TD
start["before_flush"] --> ctx["read actor and correlation id"]
ctx --> new["for each object in session.new"]
new --> create["ensure id, stamp created_by, snapshot, action create"]
ctx --> dirty["for each object in session.dirty"]
dirty --> deact{"deleted_at NULL to value?"}
deact -->|yes| stamp["stamp deleted_by, reason, note"]
deact -->|no| diff["field diff"]
stamp --> diff
diff --> classify["stamp updated_by, classify action"]
ctx --> del["for each object in session.deleted"]
del --> snap["snapshot, action delete"]
create --> add["session.add history rows"]
classify --> add
snap --> add
add --> queue["queue TenantFieldValuesChanged"]In prose:
- The listener reads
current_actor_id()andcurrent_correlation_id()(src/cluster_erp/core/change_history.py:320-321). - For each new object it assigns the primary key if it is still empty, stamps
created_by, and records a full snapshot with actioncreate(src/cluster_erp/core/change_history.py:324-329). - For each changed object it first stamps deactivation data when
deleted_atgoes fromNULLto a value, then computes the field diff. If the diff is empty, no row is written. Otherwise it stampsupdated_byand classifies the action (src/cluster_erp/core/change_history.py:331-342). - For each deleted object it records a snapshot with action
delete(src/cluster_erp/core/change_history.py:344-347). - It adds the history rows to the session and queues tenant-field events (
src/cluster_erp/core/change_history.py:349-351).
Actions¶
| Action | When | Source |
|---|---|---|
create |
A new object is flushed | src/cluster_erp/core/change_history.py:324-329 |
update |
A changed object that is not a deactivation or activation | src/cluster_erp/core/change_history.py:213 |
deactivate |
deleted_at goes from empty to set, or is_active goes from true to false while deleted_at did not move |
src/cluster_erp/core/change_history.py:202-210 |
activate |
deleted_at goes from set to empty, or is_active goes from false to true |
src/cluster_erp/core/change_history.py:204, src/cluster_erp/core/change_history.py:211-212 |
delete |
An object is physically deleted through the ORM | src/cluster_erp/core/change_history.py:344-347 |
migration |
A data migration calls record_data_migration |
src/cluster_erp/core/migration_history.py:20-53 |
The table¶
core_change_history has these columns: id, actor_id, correlation_id, entity_type (the class name), table_name, entity_id, action, changes (JSONB), and occurred_at (src/cluster_erp/core/change_history.py:78-98). It has indexes on (table_name, entity_id), actor_id, and occurred_at (src/cluster_erp/core/change_history.py:94-98). changes maps each field name to {"from":..., "to":...}; values are made JSON-safe, and UUID, dates, and Decimal become strings (src/cluster_erp/core/change_history.py:101-114, src/cluster_erp/core/change_history.py:154-176).
What is skipped¶
- Objects that do not derive from
Base, for example control-plane entities (src/cluster_erp/core/change_history.py:262-269). - Nine tables:
core_change_history,core_sequences,audit_log,deleted_records,activity_log,error_log,auth_token_blacklist,alembic_version, andcore_retention_runs(src/cluster_erp/core/change_history.py:42-52). - Six bookkeeping columns in the diff:
updated_at,version,created_by,updated_by,deleted_by, anddeactivated_by(src/cluster_erp/core/change_history.py:55).
Tenant-defined values¶
The extra JSONB bag is diffed key by key as extra.<key>, so a tenant-defined field has the same history as a column (src/cluster_erp/core/change_history.py:137-151, src/cluster_erp/core/change_history.py:172-174). Keys listed in EXTRA_REDACTED_KEYS keep their change but not their values (src/cluster_erp/core/change_history.py:130-134, src/cluster_erp/core/change_history.py:147-148). After the commit, TenantFieldValuesChanged is emitted for each changed record (src/cluster_erp/core/change_history.py:357-386). See tenant fields.
Redaction¶
is_redacted_column decides whether a value is replaced by [redacted] (src/cluster_erp/core/sensitive_fields.py:25, src/cluster_erp/core/sensitive_fields.py:61-82). The policy has four parts:
- Credential columns, matched by substring in any table (
src/cluster_erp/core/sensitive_fields.py:28-37,src/cluster_erp/core/sensitive_fields.py:56-58). - Special-category columns, as exact
table.columnpairs (src/cluster_erp/core/sensitive_fields.py:42-45). - Direct identifiers, matched exactly in any table (
src/cluster_erp/core/sensitive_fields.py:49-53). - The deployment's own list from the setting
change_history_redact_columns, installed at start-up (src/cluster_erp/config/settings.py:708-711,src/cluster_erp/server/app.py:1307,src/cluster_erp/core/change_history.py:68-71).
The same module feeds the request-log body sanitiser (src/cluster_erp/core/sensitive_fields.py:85-103). Its docstring states that the audit_log trigger carries the same lists in SQL and that tests/test_sensitive_fields_policy.py pins the two together (src/cluster_erp/core/sensitive_fields.py:1-18).
The legacy audit log¶
audit_log is an older table written by a database trigger, with actor_id, entity_type, entity_id, action, diff_jsonb, occurred_at, ip, user_agent, session_id, and correlation_id (src/cluster_erp/core/audit.py:15-69). The request session sets five app.* PostgreSQL settings that such a trigger can read (src/cluster_erp/server/dependencies.py:43-58). core/audit_backfill.py rewrites rows written before revision dm01 in small batches to remove sensitive values. It runs at start-up as a background task and on demand with python -m cluster_erp.core.audit_backfill (src/cluster_erp/core/audit_backfill.py:1-16, src/cluster_erp/server/app.py:164).
Data migrations¶
A migration that rewrites rows with raw SQL calls record_data_migration(connection, revision, table_name, note, rows), which inserts one history row with action migration and no actor (src/cluster_erp/core/migration_history.py:1-10, src/cluster_erp/core/migration_history.py:30-53). Nine files in migrations/versions/ call it (counted with grep -l record_data_migration).
Why it is built this way¶
- The module docstring states the design: one listener covers every model automatically, cannot be bypassed, and the actor comes from in-process context, so there is no module-to-module call (
src/cluster_erp/core/change_history.py:1-14). - The project's written engineering rules state that the change history is automatic, that no one writes history rows by hand, and that the actor comes from the request context.
- The
_ensure_pkdocstring states why the id is assigned early: otherwise thecreaterow would have noentity_id(src/cluster_erp/core/change_history.py:273-278).
Rules and boundaries¶
- Nothing in the module updates or deletes a history row; only the retention purge removes them (
src/cluster_erp/core/change_history.py:12-14). - Writes that bypass the ORM, such as Core
UPDATEorDELETEstatements, are not captured.bulk_soft_deleteandorm_delete_wherego through the ORM for this reason (src/cluster_erp/core/bulk_operations.py:26-34,src/cluster_erp/core/bulk_operations.py:59-61).
Interaction with other parts¶
- Database and sessions sets the actor and deactivation context.
- Soft delete and deactivation depends on the deactivation stamp.
- The
logsmodule servesGETroutes overcore_change_history(src/cluster_erp/modules/logs/change_history/routes.py:29,src/cluster_erp/modules/logs/change_history/routes.py:65,src/cluster_erp/modules/logs/change_history/routes.py:74). - Retention of history rows is documented in scheduled jobs and retention.
Limits and open points¶
- Changes made outside a request have no actor unless the caller sets the request context (
src/cluster_erp/core/change_history.py:320,src/cluster_erp/core/request_context.py:30-32). _NOISElistsdeactivated_by, but no model insrc/cluster_erpdeclares that column (searched withgrep -rn deactivated_by).
Terms introduced¶
| Term | Code identifier | Definition | Turkish term | Source |
|---|---|---|---|---|
| change history | ChangeHistory |
The append-only table of field-level changes with actor and time. | değişiklik geçmişi | src/cluster_erp/core/change_history.py:78 |
| redaction | REDACTED |
Replacing a sensitive value with [redacted] while keeping the record of the change. |
gizleme | src/cluster_erp/core/sensitive_fields.py:25 |
| correlation id | correlation_id |
An id that groups the changes of one request. | ilişki kimliği | src/cluster_erp/core/change_history.py:83 |
Related pages¶
Verified against commit 92f8051 on 2026-11-13.