Designing a Robust Slow-Changing Ownership Model in PostgreSQL and PHPRunner

This is the second iteration of a former post looking to tighten up a pattern I originally demonstrated with planning application history but here I am trying to implement with asset ownership.

One of the most reliable architectural patterns for handling changing ownership, responsibility, or allocation in business systems is to model changes as dated events rather than continually updating a single field.

This works exceptionally well for:

asset ownership
cost centre allocation
property responsibility
infrastructure stewardship
equipment assignment
operational accountability

The pattern becomes especially powerful when combined with:

UTC timestamps
timezone-aware user input
append-only history
future-dated ownership changes
user audit tracking
soft deletion

The result is a highly scalable and audit-friendly design that works extremely well in PostgreSQL and PHPRunner applications.

The Core Principle

The primary asset table stores the original ownership position:

accountcode_initial

Any subsequent ownership changes are stored separately in a history table.

This means:

the original ownership remains preserved
the complete ownership history is retained
future ownership changes can be scheduled safely
full audit history is maintained
deleted rows can be hidden without destroying history
Recommended Table Structure
Primary Asset Table

CREATE TABLE IF NOT EXISTS public.t001assets
(
    gid integer NOT NULL GENERATED ALWAYS AS IDENTITY,

    asset_name varchar(200),

    accountcode_initial varchar(100) NOT NULL,

    softdelete smallint DEFAULT NULL
        CHECK (softdelete IS NULL OR softdelete IN (0,1)),

    created_at timestamptz DEFAULT now(),

    CONSTRAINT pk_t001assets
        PRIMARY KEY (gid)
);

Ownership Change History Table

CREATE TABLE IF NOT EXISTS public.t002account_code_changes
(
    gid integer NOT NULL GENERATED ALWAYS AS IDENTITY,

    pkidt001 integer NOT NULL,

    input_time timestamp without time zone NOT NULL,

    timezone text NOT NULL,

    utc_datetime timestamptz NOT NULL,

    new_owner_accountcode varchar(100) NOT NULL,

    change_notes text,

    user_creator varchar(100) NOT NULL,

    user_editor varchar(100),

    softdelete smallint DEFAULT NULL
        CHECK (softdelete IS NULL OR softdelete IN (0,1)),

    created_at timestamptz DEFAULT now(),

    updated_at timestamptz DEFAULT now(),

    CONSTRAINT pk_t002account_code_changes
        PRIMARY KEY (gid),

    CONSTRAINT fk_t002account_code_changes_t001
        FOREIGN KEY (pkidt001)
        REFERENCES public.t001assets (gid)
);

Why This Pattern Works Well

The asset table contains the original ownership:

accountcode_initial

The history table stores only subsequent changes.

This creates:

immutable ownership history
minimal duplication
future scheduling support
audit-friendly architecture
simplified reporting
operational rollback capability

The system becomes event-driven rather than overwrite-driven.

The Soft Delete Pattern

A particularly useful refinement is adding:

softdelete

to both tables.

Recommended values:

Value Meaning
NULL Active
0 Active
1 Deleted / ignored

This provides several advantages over hard deletion:

historical integrity preserved
accidental deletion recovery
simpler auditing
safer integrations
reduced referential integrity problems

The recommended filtering pattern is:

COALESCE(softdelete,0) <> 1

This treats:

NULL as active
0 as active
1 as deleted
The Timezone Pattern

The ownership change table uses three separate fields:

Field Purpose
input_time What the user entered
timezone Intended timezone
utc_datetime Canonical UTC value

Example:

Field Value
input_time 2026-05-23 10:00
timezone Europe/London
utc_datetime 2026-05-23 09:00:00+00

This preserves both:

user intent
globally consistent ordering
Recommended Timezone Values

Always use official IANA timezone names.

Recommended examples:

Display Name Stored Value
UTC UTC
United Kingdom Europe/London
Central Europe Europe/Paris
Eastern US America/New_York
Central US America/Chicago
Pacific US America/Los_Angeles
Dubai Asia/Dubai
India Asia/Kolkata
Japan Asia/Tokyo
Sydney Australia/Sydney

Avoid ambiguous abbreviations such as:

BST
GMT
EST
PST

because daylight savings behaviour becomes unreliable.

Automatic UTC Calculation

Users should never manually edit:

utc_datetime

It should always be automatically derived.

Trigger function

CREATE OR REPLACE FUNCTION public.fn_t002account_code_changes_set_utc()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN

    IF NEW.input_time IS NULL
       OR NEW.timezone IS NULL
       OR btrim(NEW.timezone) = '' THEN

        NEW.utc_datetime := NULL;

        RETURN NEW;

    END IF;

    IF NOT EXISTS
    (
        SELECT 1
        FROM pg_timezone_names
        WHERE name = NEW.timezone
    )
    THEN
        RAISE EXCEPTION
            'Invalid timezone: %',
            NEW.timezone;
    END IF;

    NEW.utc_datetime :=
        NEW.input_time AT TIME ZONE NEW.timezone;

    RETURN NEW;

END;
$$;

Trigger

CREATE TRIGGER trg_t002account_code_changes_set_utc
BEFORE INSERT OR UPDATE OF input_time, timezone
ON public.t002account_code_changes
FOR EACH ROW
EXECUTE FUNCTION public.fn_t002account_code_changes_set_utc();

Audit Tracking

A major refinement is adding:

user_creator
user_editor
created_at
updated_at

This creates a fully auditable ownership timeline.

The system can then record:

who created the ownership change
who last edited it
when it was created
when it was modified
when it becomes effective
Recommended Audit Trigger

CREATE OR REPLACE FUNCTION public.fn_t002account_code_changes_audit()
RETURNS trigger
LANGUAGE plpgsql
AS $$
BEGIN

    IF TG_OP = 'INSERT'
    THEN

        NEW.created_at := now();

        NEW.updated_at := now();

        IF NEW.user_creator IS NULL
        THEN
            NEW.user_creator := current_user;
        END IF;

        NEW.user_editor := NEW.user_creator;

    ELSIF TG_OP = 'UPDATE'
    THEN

        NEW.updated_at := now();

        IF NEW.user_editor IS NULL
        THEN
            NEW.user_editor := current_user;
        END IF;

    END IF;

    RETURN NEW;

END;
$$;

Audit Trigger

CREATE TRIGGER trg_t002account_code_changes_audit
BEFORE INSERT OR UPDATE
ON public.t002account_code_changes
FOR EACH ROW
EXECUTE FUNCTION public.fn_t002account_code_changes_audit();

Supporting Future Ownership Changes

One of the strongest advantages of this design is the ability to safely schedule future ownership changes.

Example:

Asset UTC Datetime Owner Status
Vehicle A 2026-01-01 10:00 ACC100 Historical
Vehicle A 2026-06-01 09:00 ACC200 Current
Vehicle A 2026-09-01 09:00 ACC300 Future

The future row already exists in the database but should not become active until its effective date arrives.

The Critical Requirement

If an asset has:

no ownership change rows
or no effective ownership change rows yet

then the system should return:

accountcode_initial

from:

t001assets

This makes the ownership model operationally complete.

Current Ownership View

CREATE OR REPLACE VIEW public.v001currentassetownership AS

WITH latest_changes AS
(
    SELECT DISTINCT ON (c.pkidt001)

        c.pkidt001,

        c.new_owner_accountcode,

        c.utc_datetime,

        c.user_creator,

        c.user_editor,

        c.updated_at

    FROM public.t002account_code_changes c

    WHERE c.utc_datetime <= now()

      AND COALESCE(c.softdelete,0) <> 1

    ORDER BY
        c.pkidt001,
        c.utc_datetime DESC,
        c.gid DESC
)

SELECT

    a.gid AS pkidt001,

    a.asset_name,

    COALESCE
    (
        lc.new_owner_accountcode,
        a.accountcode_initial
    ) AS current_accountcode,

    lc.utc_datetime AS ownership_change_utc,

    lc.user_creator,

    lc.user_editor,

    lc.updated_at

FROM public.t001assets a

LEFT JOIN latest_changes lc
    ON a.gid = lc.pkidt001

WHERE COALESCE(a.softdelete,0) <> 1;

Why This View Works Well

The logic becomes:

Situation Result
No ownership changes Initial account code
Only future changes Initial account code
Current effective change exists Latest effective change
Multiple historical changes Most recent effective change

The system always derives the current state dynamically from historical events.

Future Ownership View

Future scheduled changes can also be exposed separately.

CREATE OR REPLACE VIEW public.v002futureassetownership AS

SELECT

    c.pkidt001,

    a.asset_name,

    c.new_owner_accountcode,

    c.utc_datetime,

    c.timezone,

    c.user_creator,

    c.user_editor

FROM public.t002account_code_changes c

INNER JOIN public.t001assets a
    ON a.gid = c.pkidt001

WHERE c.utc_datetime > now()

  AND COALESCE(a.softdelete,0) <> 1

  AND COALESCE(c.softdelete,0) <> 1

ORDER BY
    c.pkidt001,
    c.utc_datetime ASC,
    c.gid ASC;

Recommended Index

For performance:

CREATE INDEX idx_t002account_code_changes_asset_utc
ON public.t002account_code_changes
(
    pkidt001,
    utc_datetime DESC
);

This significantly improves ownership lookups.

Recommended Timezone Lookup Table

A very useful refinement is maintaining a dedicated timezone lookup table rather than allowing free-text timezone entry.

This provides:

validation
consistent naming
cleaner PHPRunner dropdowns
simpler administration
safer reporting
Timezone Table

CREATE TABLE IF NOT EXISTS public.t003timezones
(
    gid integer NOT NULL GENERATED ALWAYS AS IDENTITY,

    timezone_name varchar(100) NOT NULL,

    timezone_display varchar(200) NOT NULL,

    timezone_active boolean DEFAULT true,

    created_at timestamptz DEFAULT now(),

    CONSTRAINT pk_t003timezones
        PRIMARY KEY (gid),

    CONSTRAINT uq_t003timezones_name
        UNIQUE (timezone_name)
);

Recommended Seed Data

INSERT INTO public.t003timezones
(
    timezone_name,
    timezone_display
)
VALUES
('UTC', 'UTC'),
('Europe/London', 'United Kingdom'),
('Europe/Paris', 'Central Europe'),
('America/New_York', 'Eastern United States'),
('America/Chicago', 'Central United States'),
('America/Los_Angeles', 'Pacific United States'),
('Asia/Dubai', 'Dubai'),
('Asia/Kolkata', 'India'),
('Asia/Tokyo', 'Japan'),
('Australia/Sydney', 'Sydney');

PHPRunner Lookup Configuration

In PHPRunner the timezone field would normally use:

Setting Value
Link field timezone_name
Display field timezone_display

This allows users to see friendly values such as:

United Kingdom
Central Europe
Eastern United States

while PostgreSQL stores the official IANA timezone identifiers.

Final Thoughts

This pattern combines several very strong architectural principles:

immutable history
UTC canonical timestamps
timezone-aware user entry
append-only ownership changes
future scheduling support
soft deletion
audit tracking
derived current-state views

The result is a highly scalable, operationally safe, and audit-friendly ownership model that performs exceptionally well in PostgreSQL and PHPRunner applications.