Factorizer/Cluster ERP WikiCluster ERP Türkçe
Home / Platform

Explanation

Change history

How core/change_history.py records every create, update, deactivate, activate, and delete of every mapped entity with actor, time, and a field-level diff.

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_flush listener on Session captures every insert, update, and delete of every object whose class derives from Base (src/cluster_erp/core/change_history.py:318-351, src/cluster_erp/core/change_history.py:262-270).
  • History rows go to core_change_history in 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.py so 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:

  1. The listener reads current_actor_id() and current_correlation_id() (src/cluster_erp/core/change_history.py:320-321).
  2. For each new object it assigns the primary key if it is still empty, stamps created_by, and records a full snapshot with action create (src/cluster_erp/core/change_history.py:324-329).
  3. For each changed object it first stamps deactivation data when deleted_at goes from NULL to a value, then computes the field diff. If the diff is empty, no row is written. Otherwise it stamps updated_by and classifies the action (src/cluster_erp/core/change_history.py:331-342).
  4. For each deleted object it records a snapshot with action delete (src/cluster_erp/core/change_history.py:344-347).
  5. 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, and core_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, and deactivated_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.column pairs (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_pk docstring states why the id is assigned early: otherwise the create row would have no entity_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 UPDATE or DELETE statements, are not captured. bulk_soft_delete and orm_delete_where go 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 logs module serves GET routes over core_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).
  • _NOISE lists deactivated_by, but no model in src/cluster_erp declares that column (searched with grep -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

Verified against commit 92f8051 on 2026-11-13.

Verified against commit 92f8051 · 2026-11-13