Shipmind Labs

Two clocks on a permitted send: expiry and window

· 9 min read

Time bounds an approval for an outbound action twice, and the two bounds belong to different people. One is the lifetime of the approval itself, counted from the moment a human decided. The other is the window the proposer attached to the revision the approver actually read. If your executor enforces only one of them, it will either act on a decision that has gone stale, or act at a time nobody agreed to.

We ran into this building exactsend, our library for binding human approval to the exact bytes an assistant sends to a provider. Approvals there are single-use and time-limited. The word time-limited turned out to mean two things at once, and keeping them separate is what makes a refusal cheap.

The two bounds are not the same bound#

expiresAt belongs to the decision. It gets set when the approval is recorded: decidedAt plus the action type's ttlMs, counted from the human decision. It measures how long a judgement stays good. Ten minutes after someone read a payment email and said yes, the context they judged in has drifted (the thread moved, the invoice may already be settled). The TTL says: if this has not run by now, ask again.

The window belongs to the revision. The proposer declares when the action may run at all, a payout that must land inside a settlement window, a notice that must not go out before an embargo lifts, and the approver sees that window as part of what is being approved. It describes the action itself, not the decision about it.

Collapsing them fails in both directions. If the window is the only bound, an approval granted on Monday can still fire on Friday inside a week-long window, and nobody re-examined it. If the TTL is the only bound, an approval granted five minutes before an embargo lifts can fire immediately, four minutes early, with a valid decision behind it. The bounds stay independent because the questions are independent: is this decision still fresh? and is now a time this action was allowed to happen?

Both ceilings live on the action type, which exists only because code registered it:

typescript
const emailSend = defineActionType({
  name: 'email.send',
  reversible: false,
  ttlMs: 10 * 60_000,
  maxWindowMs: 60 * 60_000,
  approverRoles: ['ops-lead', 'finance'],
  maxBatch: 5,
  adapter: emailAdapter,
});

ttlMs caps the decision; maxWindowMs caps what a proposer may ask for. defineActionType refuses a maxWindowMs shorter than its ttlMs, which keeps the pair in a sane relation: if a decision could outlive every window the type permits, the window would be the tighter bound every time and the TTL would be decorative.

The window itself gets validated when the revision is created, as a closed shape:

typescript
if (notAfter <= notBefore) {
  throw new ProposalError(
    'invalid-window',
    `${path}.notAfter`,
    'notAfter must be later than notBefore',
  );
}
if (notAfter - notBefore > maxWindowMs) {
  throw new ProposalError(
    'window-too-wide',
    path,
    `a window of ${String(notAfter - notBefore)} ms is wider than the ${String(maxWindowMs)} ms this action type allows`,
  );
}

It holds notBefore and notAfter and nothing else (any other key is an invalid-window refusal), and both must be non-negative safe integers of epoch milliseconds, in order. A window you cannot compare with a single number is not a bound, it is a suggestion.

One reading of the clock, before anything is spent#

Executor.execute resolves everything it needs first, reads the clock once, and only then decides:

typescript
async execute(approvalId: string): Promise<ExecutionRecord> {
  const approval = await this.#store.getApproval(approvalId);
  const proposal = this.#proposals.get(approval.proposalId);
  const revision = findRevision(proposal, approval.revision);
  const adapter = this.#registry.adapterFor(proposal.type);
  const now = this.#now();

  if (now > approval.expiresAt) {
    throw new ExecuteError(
      'approval-expired',
      approvalId,
      `this approval expired at ${String(approval.expiresAt)}, so it decides nothing now`,
    );
  }
  if (now < revision.window.notBefore || now > revision.window.notAfter) {
    throw new ExecuteError(
      'outside-window',
      approvalId,
      'the clock is outside the window the approver saw on this revision',
    );
  }

  const intent = await this.#store.consumeAndRecordIntent(
    approvalId,
    crypto.randomUUID(),
    now,
  );

Three things in that ordering are deliberate.

The clock gets read once. Both comparisons run against the same now, so a send cannot pass the expiry check against one reading and the window check against a later one. With two separate calls to Date.now() the gap is small, but it is a gap in which an action can be permitted by a bound that had already closed. The same now then goes to the store as the consumption timestamp, so the moment that was checked is the moment that gets recorded.

Resolution happens before the time checks, and resolution failures have their own vocabulary. An approval naming a revision the proposal does not have is unknown-revision from findRevision. A type with no registered adapter is unknown-action-type from the registry. An unknown approval id is a StoreError. None of these are time problems, and none of them should read as one. Broken wiring that surfaced as outside-window would send an operator to look at a calendar instead of at the code.

And both bounds are checked before consumeAndRecordIntent, which is the part that matters most.

A refusal has to cost nothing#

Consumption is the irreversible step. It marks the approval consumedAt, pins an executionKey to it, and writes a dispatching row. A second attempt returns null and the executor raises already-consumed. The store contract pins exactly that:

typescript
assert.equal(
  await store.consumeAndRecordIntent(record.approvalId, 'key-2', EXECUTE_AT + 1),
  null,
);
const consumed = await store.getApproval(record.approvalId);
assert.equal(consumed.consumedAt, EXECUTE_AT);
assert.equal(consumed.executionKey, 'key-1');

Because the two clock checks sit in front of that call, a refusal for being early, late or expired leaves consumedAt as null and executionKey as null. The approval is untouched. You can come back inside the window and run it.

That is the whole argument for the ordering. An executor that consumed first and validated second would turn a two-minute-early call into a burned decision: the action never went out, and the human has to approve again something they already approved. Refusals driven by a clock are probably the ones most likely to be a scheduling mistake rather than a policy violation, so they are exactly the ones that must not spend anything.

For a caller, the two outcomes are not equally recoverable, and the code can say so:

typescript
try {
  return await executor.execute(approvalId);
} catch (error) {
  if (error instanceof ExecuteError && error.code === 'outside-window') {
    // early: the approval is intact, retry inside revision.window
  }
  if (error instanceof ExecuteError && error.code === 'approval-expired') {
    // terminal: not because anything was spent, but because the clock does not go back
  }
  throw error;
}

outside-window before notBefore means wait. approval-expired means ask a human again. Neither leaves residue in the store.

Rescheduling is a new revision, not a new window#

An approval binds a revision number and the digest of that revision's bytes. The window lives on the revision. Those two facts together close a hole that is easy to leave open: there is no way to move the time an existing approval can fire.

If the schedule changes, the proposer appends a new revision with its own window and its own digest. findRevision resolves approval.revision, so an approval for revision 1 reads revision 1's window forever, and nothing about revision 2 applies to it. The new revision has no approval, so someone has to look at it. This is the same property the digest gives us for content (an approval names exact bytes) carried over to time. A window the approver saw cannot be widened behind their back, because widening it produces a different revision, and approvals do not follow revisions forward.

What it costs to run#

Proposers have to state a window on every revision, in epoch milliseconds. That is real friction: whenever is not an accepted answer, and code that previously enqueued an action now has to say when it is allowed to happen. In our experience with payment and notification paths that question had an answer every time; it was simply never written down anywhere a machine could check.

Operators get refusals that look like failures and are not. A scheduler firing at a window boundary will see outside-window, and your retry logic has to treat it as not yet rather than as an error to page on. The distinction is carried in the code, not in a log message, which is why the two conditions have separate codes instead of one not-permitted-now.

Tightening is possible, widening is not. Policy overrides are parsed against a closed schema covering ttlMs, approverRoles and maxBatch, and a longer TTL is refused with ttl-not-tightened. Configuration can shorten the life of a decision. It cannot extend either bound, and maxWindowMs is not overridable from configuration at all.

Testing is cheap because the clock is an input. ExecutorOptions takes a now function, defaulting to Date.now, so the store contract runs every scenario against a pinned executeAt, and the boundary cases (one millisecond before notBefore, one past expiresAt) are ordinary assertions rather than sleeps.

Two bounds, two owners, one reading of the clock, and nothing spent until both have passed. Reconciliation with the provider before a retry is the next piece and is not implemented yet, but it starts from the same place: the state a refusal leaves behind is as much a design decision as the send itself.

Was this useful?

Building something similar?

or email hello@shipmindlabs.com