Logging Change Order Schedule Impacts to an Audit Trail
An approved change order does two things to a job: it moves money, and it moves time. The cost side is tracked meticulously; the time side is usually reconstructed months later from memory and marked-up schedules. This page solves one precise problem: how to write a tamper-evident, append-only record that links an approved change order to the exact finish-date and float impact it caused, with a hash chain that lets an auditor prove the entry was never altered. That record is what converts “the owner’s added scope pushed us three weeks” from an argument into evidence. It belongs to schedule impact logging under the broader schedule sync integration work, and it pairs the change order’s Decimal cost delta with the integer-day schedule movement so a single durable record carries both dimensions of the impact. This page targets Python automation builders and project controls engineers who need change-order delay evidence that survives a claim review.
Key Rules and Specification
A change-order impact record is evidence, so it is governed by strict constraints at the boundary:
- Append-only, never mutable. The record is a
frozenPydantic model. Once written it cannot be edited; a correction is a new record that references the original, never an in-place update. - Cost is
Decimal, time isintdays. The change order’s cost delta is aDecimalto two places — never a float — while the finish-date movement and float consumption are integer working days. - The change-order linkage is mandatory. A record whose cause is a change order must carry the
change_order_id; an impact with no source id is an assertion, not evidence, and is rejected at validation. - Timestamps are timezone-aware. The
event_timefollows ISO 8601; a naive datetime is rejected because it lets the other side dispute which day the impact fell on. - Every record chains. The
prev_hashholds the previous record’s SHA-256 digest, and the writer refuses any record whoseprev_hashdoes not match the current chain head.
| Element | Type / rule | Purpose |
|---|---|---|
change_order_id |
str, required |
Binds the impact to its approved source |
cost_delta |
Decimal, 2 dp |
Money moved by the change order |
delta_days |
int |
Finish-date movement in working days |
float_consumed_days |
int, ge=0 |
Float the change order burned |
event_time |
tz-aware datetime |
When the impact landed on the schedule |
prev_hash |
^[0-9a-f]{64}$ |
Chains to the previous record’s digest |
Production Code Example
The record below is a frozen contract: cost is Decimal, day movements are int, and the change-order linkage is enforced by a validator so an unlinked impact can never be written. The digest is computed over the evidentiary content plus the previous hash, following the standard decimal module for exact money.
from __future__ import annotations
import hashlib
import logging
from datetime import datetime, timezone
from decimal import Decimal
from uuid import UUID, uuid4
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
logger = logging.getLogger("schedule.impact.change_order")
GENESIS_HASH = "0" * 64
WBS_PATTERN = r"^PROJ-\d{3}-(ARCH|STR|MEP|CIV|ELEC|PLMB)-\d{2}$"
class ChangeOrderImpact(BaseModel):
"""An immutable, hash-chained record of a change order's schedule impact."""
model_config = ConfigDict(frozen=True)
schema_version: str = "1.0"
record_id: UUID = Field(default_factory=uuid4)
project_uuid: UUID
change_order_id: str = Field(min_length=1, max_length=40)
activity_id: str = Field(min_length=1, max_length=40)
wbs_node: str = Field(pattern=WBS_PATTERN)
event_time: datetime # when the impact hit the schedule
cost_delta: Decimal = Field(decimal_places=2) # money moved (may be negative)
delta_days: int # finish movement; positive = slip
float_consumed_days: int = Field(ge=0)
critical_path_changed: bool
prev_hash: str = Field(pattern=r"^[0-9a-f]{64}$")
@field_validator("event_time")
@classmethod
def require_tz_aware(cls, v: datetime) -> datetime:
if v.tzinfo is None:
raise ValueError("event_time must be timezone-aware (ISO 8601)")
return v.astimezone(timezone.utc)
@model_validator(mode="after")
def linkage_present(self) -> "ChangeOrderImpact":
# A slip attributed to a change order is only defensible with the id.
if not self.change_order_id.strip():
raise ValueError("change_order_id is required for a change-order impact")
return self
def content_digest(self) -> str:
payload = self.model_dump_json()
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
class ChangeOrderImpactTrail:
"""Append-only writer that chains each impact to its predecessor."""
def __init__(self) -> None:
self._records: list[ChangeOrderImpact] = []
@property
def head_hash(self) -> str:
return self._records[-1].content_digest() if self._records else GENESIS_HASH
def write(self, **fields) -> ChangeOrderImpact:
"""Build a record chained onto the current head and append it."""
record = ChangeOrderImpact(prev_hash=self.head_hash, **fields)
self._records.append(record)
logger.info(
"impact.written | co=%s activity=%s days=%d cost=%s digest=%s",
record.change_order_id, record.activity_id, record.delta_days,
record.cost_delta, record.content_digest()[:12],
)
return record
def verify(self) -> int | None:
"""Return the index of the first tampered record, or None if intact."""
expected = GENESIS_HASH
for idx, rec in enumerate(self._records):
if rec.prev_hash != expected:
return idx
expected = rec.content_digest()
return None
if __name__ == "__main__":
trail = ChangeOrderImpactTrail()
pid = uuid4()
trail.write(
project_uuid=pid, change_order_id="CO-0042", activity_id="A1200",
wbs_node="PROJ-014-STR-02",
event_time=datetime(2026, 7, 3, 12, 0, tzinfo=timezone.utc),
cost_delta=Decimal("18400.00"), delta_days=11, float_consumed_days=0,
critical_path_changed=True,
)
trail.write(
project_uuid=pid, change_order_id="CO-0043", activity_id="A1330",
wbs_node="PROJ-014-MEP-04",
event_time=datetime(2026, 7, 9, 15, 30, tzinfo=timezone.utc),
cost_delta=Decimal("6250.00"), delta_days=4, float_consumed_days=4,
critical_path_changed=False,
)
print("chain intact:", trail.verify() is None)Because the writer always sets prev_hash to the current head, the caller cannot accidentally chain a record to the wrong place, and verify() recomputes the whole chain to prove nothing was altered after the fact.
Common Mistakes and Gotchas
Mutable records that can be edited after the claim. Leaving the model unfrozen — or storing impacts as plain dictionaries — means a delta_days value can be “corrected” upward when a claim is being built, which is exactly what the opposing scheduler will allege. Freeze the record and append corrections as new entries. An in-place edit both destroys the entitlement argument and breaks the hash chain, which verify() will expose at the next record.
Writing an impact with no change-order linkage. A record that says the schedule slipped eleven days but does not name which approved change order caused it is not evidence — it is an unsupported assertion that a reviewer will strike. The change_order_id is mandatory for a reason; if the delta cannot be tied to a specific approved change order, log it under the neutral cause in schedule impact logging instead and let a scheduler attribute it, rather than stamping a change-order cause without the id.
Timezone-naive event_time. A record whose event time is written in a site’s local clock without a zone gets read as UTC downstream, sliding the impact by hours and, across a midnight boundary, by a whole day — enough to change which daily report the delay lands in. Attach the site zone at the source and let the validator reject anything naive; never normalize a naive timestamp to UTC after the fact, because that silently moves the evidence.
Where This Fits in the Pipeline
This writer is the change-order path of schedule impact logging, which sits inside schedule sync integration. Upstream, the finish-date movement and float figures come from critical path delta detection, and the approved change order itself is parsed by automated document ingestion. Its sibling path, linking RFI response delays to schedule slippage, writes the same kind of chained record for a different cause — a delayed RFI answer rather than an approved change — so both delay sources land in one tamper-evident trail. Records that cannot be attributed cleanly hand off to fallback alert routing for triage.
Frequently Asked Questions
Why store the cost delta on the schedule impact record at all?
A change order’s time and cost impacts are argued together in a claim, and keeping them on one chained record means the day movement and the Decimal dollar figure share a single tamper-evident entry. An auditor can see that the eleven-day slip and the $18,400 both trace to CO-0042 without joining two systems that could drift apart.
What happens if I need to correct a delta that was logged wrong?
You append a new record that references the original record_id and carries the corrected delta_days, never an in-place edit. The original entry stays in the chain, the correction sits after it, and verify() still passes because no existing hash changed. This preserves the full history, which is exactly what a claim reviewer wants to see.
Can delta_days be negative?
Yes. A change order can accelerate work — a de-scope or a resequence that pulls the finish date in — so a negative delta_days records a recovery. Float consumed stays non-negative because you cannot un-consume float, but the finish movement is signed so both slips and recoveries are captured on the same trail.
How does the writer stop a duplicate record on a retry?
The writer itself is thin, so the outer task keys its commit on project_uuid, change_order_id, and activity_id; a redelivered message finds the record already present and skips the append. Without that guard a broker retry would chain a second identical impact and double-count the change order’s delay in the claim total.
Why SHA-256 over the JSON dump rather than a database checksum?
Hashing the model’s canonical model_dump_json() output makes the digest reproducible anywhere Python runs, so an auditor with only the exported records can recompute the chain independently of the database. A database-internal checksum proves nothing once the data has left that database, which is precisely when a claim is being litigated.
Related
- Schedule Impact Logging
- Linking RFI Response Delays to Schedule Slippage
- Critical Path Delta Detection
- Automated Document Ingestion & Parsing
- Fallback Alert Routing
← Back to Schedule Impact Logging