Why extending an acceptance deadline is a write, not a recomputation
Pausing an acceptance clock changes a deadline, and a deadline that gets recomputed on demand belongs to whoever recomputed it last. In a two-party contracting engagement that is not an academic worry: the client and the contractor each replay the same journal on their own machine, and if the deadline is derived rather than recorded, one calendar revision or one zoneinfo update between the two runs is enough to make them disagree about whether a milestone was accepted by silence. In engageledger I record an extension the way I record the original delivery deadline: computed once, on the calendar version the running window already named, written into the journal as a single UTC instant, and read back later instead of derived again.
The three inputs that can move underneath a running window#
The acceptance clock counts business days. bizday.Calendar{version, zone, weekend, holidays, close} carries the zone the client counts days in, which weekdays are weekend, the holidays the contract lists, and the local time a business day ends. Calendar.Deadline(deliveredAt, n) gives you the close of the n-th business day after a delivery as one UTC instant: a delivery past close counts from the next business day, weekends and listed holidays are skipped, and the wall-clock close maps to a different instant on either side of a daylight-saving transition.
It is a pure function, which is why calling it twice is dangerous. Three of its inputs can change while a window is open:
- The calendar version.
calendar.revisedappends another calendar version with its own hash. Deliveries recorded after it count on it; windows opened before it keep the version they named. - The zone database. The zoneinfo database is embedded so that the same journal reads the same deadline on any host, but embedded is not frozen: a rebuild can carry a corrected transition.
- The days. Every effective hold adds business days to the window it pauses.
Any one of those can move a deadline by a day, and a day is the difference between a milestone still open and one that is deemed_accepted.
A delivery fixes its deadline once#
acceptance.Deliver writes a milestone.delivered event carrying the computed instant as deadline_utc next to the calendar_hash it was computed on. The fold reads both and stores them, and it does not call Deadline again. The delivery side is also where the rule about which calendar version may be named gets enforced:
// fix reads the deadline a delivery recorded and the calendar version it counts
// on, which must be the one in force where the delivery stands. The instant
// enters the window as recorded: nothing here recomputes it, so a revised
// calendar or an updated zone database cannot move a deadline the journal
// already carries. Verify is what reports a recorded deadline this replay
// would compute elsewhere.
func (f *folder) fix(w *Window, body map[string]any) error {
head, ok := f.head()
if !ok {
// a contract that opened on no calendar may carry no deadline at all
...
}
hash, err := readText(body, "calendar_hash")
if err != nil {
return err
}
if hash != head.Hash {
if old, known := versionOf(f.calendars, hash); known {
return fmt.Errorf("counts on calendar %s, which the revision at event %d superseded with %s",
short(old.Hash), head.Seq, short(head.Hash))
}
return fmt.Errorf("names calendar %s, which no contract.opened or calendar.revised before it appended", short(hash))
}
at, err := readInstant(body, "at")
...
w.Delivered, w.Deadline, w.CalendarHash = at, deadline, hash
return nil
}A new delivery has to count on the head calendar. That is the asymmetry to hold on to, because an extension does the opposite.
An extension counts on the window's calendar, not on the head#
A hold is a pause of one milestone between two dates. hold.proposed names it, hold.approved carries one party's approval of that proposal, evidenced the way a change order's approval is, and the hold pauses nothing until every party has approved. Only then does it get an Effective flag set and days worth adding:
// Hold is one pause of the acceptance clock of a milestone: the dates it
// covers, the business days of the calendar the paused window counts on that
// fall between them, and the approvals behind it.
type Hold struct {
Seq int64
ID string
Milestone string
Start string
End string
Days int64
Approvals []approval.Approval
Effective bool
EffectiveBy int64
ExtendedBy int64
}The body of the extension is computed by one party, once:
// Extension is the deadline.extended body of the hold id, computed once with
// the calendar version the paused window recorded: the close of the business
// day the term of that window reaches once the days the hold covered are added
// to it.
func (a Acceptance) Extension(id string) (map[string]any, error) {
h, ok := a.Hold(id)
if !ok {
return nil, fmt.Errorf("acceptance: hold %q is not in the journal", id)
}
if !h.Effective {
return nil, fmt.Errorf("acceptance: hold %q carries %d approvals, so it pauses nothing", id, len(h.Approvals))
}
if h.ExtendedBy > 0 {
return nil, fmt.Errorf("acceptance: event %d already extended the deadline on hold %q", h.ExtendedBy, id)
}
m, ok := a.Find(h.Milestone)
if !ok {
return nil, fmt.Errorf("acceptance: milestone %q is not in the contract", h.Milestone)
}
w, open := m.Open()
if !open {
return nil, fmt.Errorf("acceptance: milestone %q is %s, with no open acceptance window to extend", m.ID, m.describe())
}
v, ok := versionOf(a.Calendars, w.CalendarHash)
if !ok {
return nil, fmt.Errorf("acceptance: the window of milestone %q counts on calendar %s, which the journal does not carry",
m.ID, short(w.CalendarHash))
}
deadline, err := v.Calendar.Deadline(w.Delivered, w.Days+w.Held+h.Days)
if err != nil {
return nil, err
}
return map[string]any{
"milestone": m.ID,
"hold": id,
"deadline_utc": deadline.Format(time.RFC3339),
"calendar_hash": w.CalendarHash,
}, nil
}Two details carry the whole design. The lookup is versionOf(a.Calendars, w.CalendarHash), the version the window named at delivery, not the head. If a revision landed while the window was running, the extension still counts on the older version, because the window's term was measured on that one and a pause should not quietly re-measure the term. Then the day count: w.Days + w.Held + h.Days, where Held is the days earlier holds already added, so a second pause extends the window the first one produced rather than the one the delivery produced.
The append is a thin wrapper. The body it writes is what every later reader will trust:
// Extend appends the deadline.extended event of the hold id. A hold that pauses
// nothing leaves the journal untouched.
func (a Acceptance) Extend(ap *journal.Appender, id string) (journal.Event, error) {
body, err := a.Extension(id)
if err != nil {
return journal.Event{}, err
}
return ap.Append(EventExtended, body)
}From the caller's side you get three appends and a re-read:
if _, err := ap.Append("hold.proposed", acceptance.HoldBody("h1", "m2", "2026-03-09", "2026-03-11")); err != nil {
return err
}
for _, a := range partyApprovals { // one per party of the contract
if _, err := ap.Append("hold.approved", acceptance.ApprovalBody("h1", a)); err != nil {
return err
}
}
st, err := acceptance.Fold(...) // re-read: the hold is Effective only now
if err != nil {
return err
}
if _, err := st.Extend(ap, "h1"); err != nil {
return err
}What the replay refuses#
Writing the instant down moves the risk instead of removing it: a recorded deadline is a number someone chose. So the replay does not recompute the extension, it audits the claim. Its extend step refuses the event unless all of this holds:
- the milestone has an open window, since an extension cannot reopen a settled acceptance;
- the hold is in the journal and pauses this milestone;
- the hold is
Effective, so one party cannot extend a deadline alone; - the hold has not been extended before, which is what
ExtendedByis for (otherwise the same approved pause could be spent twice); - the hold's
Seqis not below the window'sSeq, so a hold proposed before this window opened cannot extend it; - the event's
calendar_hashequals the window'sCalendarHash, so an extension computed on a revised calendar is not accepted against a window counting on the old one; - the recorded
deadline_utcis not before the window's currentDeadline.
Only then does it add h.Days to Held, set Deadline to the recorded instant, and mark the hold ExtendedBy the event's Seq.
That last check is the one I would keep if I had to drop the other six. A pause is supposed to give the client more time to review, so an extension that moves the deadline earlier is a shortening wearing an extension's event type. Monotonic-forward is a property the replay can check without redoing the arithmetic, and it is probably the property a party would try to violate.
Drift is reported, never applied#
Acceptance.Verify is the audit path. It recomputes every recorded deadline with the calendar version whose hash the delivery named and the window's own w.Days + w.Held, and it returns a Drift{seq, milestone, recorded, recomputed, cause} for each one that comes out elsewhere: tzdata where the day the window closes on holds and only the instant of that close moves, calendar where the day itself moves. It writes none of them back.
Because Held is part of that recomputation, an extension gets audited on exactly the same terms as the original delivery deadline: a window that was paused is still checkable against a fresh computation, and a disagreement surfaces as a reported fact instead of as a silently different answer on two machines. Neither a zoneinfo update nor a calendar correction can shorten a window that is already running. What they can do is make the drift visible, which is what a contract dispute actually needs.
What I would do differently#
The first shape I reach for in a model like this is the derived one: store the hold's start and end dates, store the calendar, compute the deadline whenever someone asks. Less code, and it cannot go stale. It also makes the deadline a function of the reader's environment, and once money and acceptance-by-silence hang off that instant, "a function of the reader's environment" is the bug.
What I underrated at first was the asymmetry: a delivery must name the head calendar, an extension must name the version its window already recorded, and the two rules live in different functions with similar-looking hash comparisons. Today I would make that pair explicit in the naming of the two reads rather than leaving the distinction to the error strings, because it is the one place where a plausible-looking change ("just use the head everywhere") quietly re-measures a term that is already running...
The general rule I carry out of this: if a value decides whether a party owes something, record the value, not the recipe. Keep the recipe for auditing, and let it report rather than correct.