polycratia

A resumed acceptance window is measured by the rejection that stopped it

· 9 min read

Most contracts hand a delivery ten business days to be accepted and then go quiet about what a rejection does to that count. Does the redelivery get a fresh ten days, or only what the first window had left? On a long engagement that term is worth real money, so code that replays a contract has to read it out of the contract rather than choose for it. Resuming is the harder half: a clock you resume needs a measurement of how much of it went already, and in an append-only journal that measurement can only live in the rejection that stopped it.

This comes out of engageledger, a Go library I maintain. It holds the money terms of a contracting engagement as a signed append-only journal, then folds that journal into the current terms and the acceptance state of every milestone. Acceptance there is a business-day clock: a delivery opens a window, an acceptance or a rejection closes it, and a window nobody closed runs out into deemed_accepted. The retest rule itself is small, one switch when the head is read and one branch when the next delivery arrives. Almost everything interesting about it sits in what happens when the number those two lean on is missing or wrong.

The term lives in the contract head#

readConfig reads the optional acceptance object out of the contract.opened body:

go
func readConfig(body map[string]any) (int64, Retest, error) {
    v, ok := body["acceptance"]
    if !ok {
        return 0, RetestRestart, nil
    }
    obj, ok := v.(map[string]any)
    if !ok {
        return 0, "", errors.New("has an acceptance value that is not an object")
    }
    window := int64(0)
    if raw, ok := obj["window_days"]; ok {
        var err error
        if window, err = readCount(raw, "window_days"); err != nil {
            return 0, "", err
        }
    }
    retest := RetestRestart
    if raw, ok := obj["retest_window"]; ok {
        s, _ := raw.(string)
        switch Retest(s) {
        case RetestRestart, RetestResume:
            retest = Retest(s)
        default:
            return 0, "", fmt.Errorf("names retest_window %v, want %q or %q", raw, RetestRestart, RetestResume)
        }
    }
    return window, retest, nil
}

Two defaults are doing real work here. An absent acceptance object gives you a window of zero days and RetestRestart: a contract that never said how long acceptance runs does not get a window invented on its behalf. An unrecognised retest_window refuses the head itself, which in this fold is about as loud as failure gets. acceptance.Fold does not stop at a refused event, it records the refusal and keeps reading, and because the head never applied, f.opened stays false. Every later milestone event is then refused in turn for arriving before contract.opened. What you end up with is an empty contract next to a list of refusals, not a contract whose retest rule was quietly guessed. A typo in a negotiated term should not decay into a default.

The fold has no clock, so the rejection carries the count#

The remaining days cannot simply be computed at redelivery time, because the fold owns no clock on purpose. Deadlines are fixed once, at delivery: acceptance.Deliver computes the close of the n-th business day on the contract's calendar and writes that UTC instant into the event as deadline_utc, next to the calendar_hash it was computed on. The fold takes the instant as recorded. Acceptance.Verify can recompute every recorded deadline and report a Drift for each one that now lands elsewhere, but it writes none of them back, so a zoneinfo update cannot shorten a window that is already running.

A fold that subtracted "days used so far" at replay time would break that property in the most annoying way available: the same journal would fold to different windows depending on when you replayed it. So the elapsed days have to be a recorded number, and the rejection is the event in a position to record them:

go
func (f *folder) reject(e journal.Event, i int) error {
    m := &f.milestones[i]
    if _, open := m.Open(); !open {
        return fmt.Errorf("rejects milestone %q, whose acceptance window is not open", m.ID)
    }
    defects, err := readDefects(e.Body, m.ID, m.Criteria)
    if err != nil {
        return err
    }
    used := int64(0)
    if v, ok := e.Body["elapsed_days"]; ok {
        if used, err = readCount(v, "elapsed_days"); err != nil {
            return err
        }
    }
    m.Status = StatusRejected
    w := &m.Windows[len(m.Windows)-1]
    w.Closed, w.ClosedBy, w.Used, w.Defects = true, e.Seq, used, defects
    return nil
}

readCount is the same reader the head uses for window_days: it takes a json.Number, an int64 or a plain int, so a body built in memory and the same body read back off disk behave identically, and it refuses a negative count. With the key absent it leaves used at zero.

The redelivery side is symmetric and short. A delivery is refused while a window is open; otherwise it appends a Window whose Days are the window the head configured. If the milestone's Status is StatusRejected, that window is marked as a retest, and under RetestResume its Days become the Remaining of the window that was just closed: Days minus Used, or zero once Used has reached Days.

In journal terms, a resume contract and a rejection four days in look like this:

json
{"acceptance": {"window_days": 10, "retest_window": "resume"}}
{"milestone": "m1", "defects": [{"criterion": "c1", "note": "export omits the tax column"}], "elapsed_days": 4}

The next milestone.delivered opens a retest window of six business days, and the deadline for those six days gets computed and recorded at that moment, on the calendar version the delivery names.

The two ways a recorded count goes wrong#

The design keeps the fold pure, and the price is that elapsed_days is load-bearing. It fails in exactly two ways, and neither of them is an error.

Omission. A rejection with no elapsed_days leaves Used at zero, so Remaining equals Days, so the retest runs the full term. Under retest_window: "restart" you cannot see it, since the count is decoration there: the next window is the full term anyway. Under "resume" the contract quietly delivers restart semantics, and nothing in the journal is inconsistent, because a rejection written on the delivery day genuinely spends zero days. The default is the most generous value available to the contractor, and it reads exactly like a true same-day rejection.

Overstatement. A rejection that records more days than the window had leaves Remaining at zero, and the retest opens a window of zero days. That is not refused either, and it is not a void window: it has a deadline like any other, the close of the delivery day itself:

go
spentWindow, open := milestoneOf(t, spent, "m1").Open()
if !open {
    t.Fatal("the retest opened no acceptance window")
}
if spentWindow.Days != 0 {
    t.Errorf("a retest after a window spent over runs %d days, want 0", spentWindow.Days)
}
if stamp := spentWindow.Deadline.Format(time.RFC3339); stamp != "2026-03-06T16:00:00Z" {
    t.Errorf("a retest of no days runs to %s, want the close of the delivery day", stamp)
}

That is the behaviour I want, and it falls out of the calendar instead of a special case. Deadline for a window of zero business days is the close of the day the delivery landed on, and a delivery past close counts from the next business day. Deem then moves the milestone to deemed_accepted once that instant has passed, and its guard is the window the head configured, not the days of the particular window that happens to be open. A contract that configured no acceptance window at all has no clock to run out. A window that resumed down to zero days does have one, and it closes at close of business.

So the failure is quiet at both ends. One direction hands the contractor a free full window, the other hands the client an acceptance that is over by the end of the delivery day. Both read as ordinary journal lines.

What I would do differently#

I would make elapsed_days mandatory whenever the head says resume, and refuse the rejection without it.

The fold already has that instinct elsewhere. A milestone.rejected event has to name at least one criterion of the milestone, each with a non-empty defect note; a rejection that names none fails no criterion, so it is refused and the milestone stays delivered with its clock still running. The reasoning is that a rejection which does not say what is wrong has not stopped anything. Under resume semantics, a rejection that does not say when it arrived has not measured anything either, and unlike the missing defect it does not leave the state untouched, it moves the window in the contractor's favour.

The second change is on the writing side. Deliver computes the deadline so that a caller cannot hand-type one. There is no equivalent helper for a rejection, so elapsed_days is whatever the caller counted. A Reject that derives the count from the recorded deadline and the instant of the rejection would make the number a consequence of the same calendar that produced the deadline, instead of a second opinion about it. The count would still be recorded in the event, and that part is not negotiable, because the fold has to stay replayable, but it would stop being the one field in the acceptance path that is produced by hand.

The general shape is probably worth keeping in mind beyond this library. When you move a clock out of your state machine and into recorded events, you buy determinism and you take on a new obligation: every recorded quantity now has a default, and that default is a business rule whether you chose it or not. Zero is rarely the neutral value it looks like.

react

$ new-project --brief

or email hey@polycratia.com