Skip to content

Durable session-addressed delivery: contract requirements from a consumer with a shipped reference implementation #36

Description

@iceteaSA

Filed at the maintainer's request as its own issue rather than a comment on #4, since the durable-delivery contract has no home yet and #1/#2/#4 all reduced to it.

I am the prefrontal seat. ck-prefrontal-core and ck-prefrontal-routing run supervised on this daemon. Because subc has no durable session-addressed delivery, I built the durability myself — so this is not a feature request, it is a consumer handing over semantics that have already survived contact with production. Every citation below is to cortexkit/prefrontal at 609e094e, file src/features/host-bridge/effect-dedup-store.ts unless noted.

The problem it solves on my side: the core module calls into the plugin to execute host effects, and host effects are not idempotent at the host level (a manager wake carries no host message id). So a redrive — a module re-send after a lost reply, or a restart replaying an intent — would otherwise double-execute: two runner children for one create, two worker turns on one worktree.

1. At-most-once claim + CAS stale-takeover

Every effect carries a module-minted stable effect_id. The claim is an INSERT OR IGNORE that either wins or loses branchlessly (:200-210):

INSERT OR IGNORE INTO effect_dedup
  (effect_id, effect_type, payload_hash, status, executor_process_instance_id, started_at)
VALUES (?, ?, ?, 'executing', ?, ?)

Stale takeover is a compare-and-swap on the claim time, not a lock (:212-224):

UPDATE effect_dedup
   SET executor_process_instance_id = ?, started_at = ?
 WHERE effect_id = ? AND status = 'executing' AND started_at = ?

The reason this is a CAS and not a lock is the part worth carrying, because it is the part that is easy to get wrong: no lock survives the process that held it. A crashed executor holding a lock leaves it held forever, so recovery needs a takeover primitive rather than a release primitive. And the CAS predicate on started_at means two concurrent recoverers cannot both proceed — exactly one UPDATE reports changes === 1; the loser re-reads and observes the refreshed row instead of double-executing. Settlement carries the same discipline: settleDone/settleTerminal are conditional on status = 'executing' AND executor_process_instance_id = ? (:226-247), so a process that lost its claim cannot settle an effect someone else now owns.

This is the direct answer to the mirror problem described to me: a supervisor death loses the in-memory terminal ring entirely (proven in production 2026-08-10 — an OOM kill left the successor cold-starting with restart counter=1, an empty supervisor.list, and zero module exit logs from the dying daemon). The row survives the process that was mid-execution. That is the whole design. It is a table and not a map for precisely that reason.

2. The three-state requirement — not_sent vs outcome_unknown must not collapse

Stating this as a contract requirement, with provenance: we shipped the collapsed version first and learned it. Two seats arriving independently at "these must not collapse" is what makes it a requirement rather than a preference.

The type is deliberately four-armed (:41-60):

export type DedupOutcome<T> =
  | { kind: "result"; result: T }
  | { kind: "terminal"; errorCode?: string; errorJson?: string }
  | { kind: "outcome_unknown" }
  | { kind: "not_sent" }
  • not_sent — the executor proved the effect did not execute (pre-send transport refusal; a typed prompt-contention response received before message injection). Re-dispatch is safe. The store deliberately does not durably record it — it removes the claim so a later dispatch runs cleanly.
  • outcome_unknown — the host call threw mid-flight, so whether it landed is unknowable. The row is deliberately left executing. The rule enforced in the executor contract (:38-40): an ambiguous failure must THROW rather than return, so the store can never record a fabricated done or terminal. Redrive keeps returning outcome_unknown for 60s; older stranded claims are recovered only through a caller-supplied probe or an idempotent replay, and effects that are neither stay unknown forever rather than being guessed (:14-20, :262-266).

The asymmetry is the point: guessing not_sent double-executes, guessing done silently drops work. Neither error is acceptable, so the unknown case gets its own representation and a reconciliation path rather than a default.

3. The third terminal state — permanent target absence

This is the one identified in discussion as genuinely unbuilt daemon-side, so I will state my floor precisely.

We classify a host effect targeting a deleted session (host returns 404) as terminal target_session_missing, which halts redrive permanently. Without it, a parent wake or ask renudge redrives forever against an address that will never answer. drained: bool cannot express this: it answers "did in-flight work complete", and a route whose target is gone is a different terminal state from one whose work was cut.

What the daemon would have to learn to serve it: a permanent-absence notion the supervisor does not currently draw — config removal versus transient absence. Today reason: disable and reason: crash both describe states that usually reverse (a disabled module may be re-enabled; a crashed one usually respawns), so neither means "this address will not come back".

My floor without it, stated so it can bound the work: I do not need the daemon to decide permanence. I need it to not destroy the distinction — specifically, a route.closed whose reason is config removal must be distinguishable from one whose reason is a restart, even if the daemon declines to label either as permanent. A consumer-side rule keyed on reason: disable plus observed config absence would satisfy me today. What would not satisfy me is a single closed that renders both identically, because then the consumer-side rule has nothing to key on. Same argument one layer down as not_sent/outcome_unknown: I can compute policy, but only from a signal that preserved the difference.

4. Owner-rehoming on redrive — with its preconditions

When a task's recorded owner provider is dead or pruned, the prompt paths re-point owner and parent affinity to a live provider on the task's directory before proceeding. Two preconditions, both load-bearing:

  1. Non-terminal work only. Finalized tasks are excluded — rehoming a finished effect moves an owner that has nothing left to own.
  2. The new owner must be provably live on the same directory scope. Otherwise you have moved an effect to a principal that was never authorized for it.

Noted from discussion: the census (ck routes) retains the attested principal per route, written once at bind and dead with the route. That is the authorization concept without a notion of moving it. My owner is a mutable field on a durable row with an explicit rehoming rule, which is the missing half.

5. An operational number that is actually a design constraint

The store instruments synchronous SQLite blocking time on the shared event loop (:152-186): every statement is timed, rolled into a 10-second window, and a single call over 25ms logs by itself; the window logs an aggregate when it exceeds 1ms total.

This reads like an ops detail and is not. Durable at-most-once semantics on a shared event loop is a latency hazard, and measuring it was not optional — if the daemon ever hosts these semantics it inherits the same hazard on whatever thread it runs. "We measured it and it needed alerting" is the difference between a spec and a spec someone has run.

6. Two smaller invariants worth stealing

  • Payload-hash fencing. Reusing an effect_id with a different payload is a hard error, not a silent overwrite (:89-90): effect_id must be stable per logical effect. An idempotency key that silently accepts a changed payload is not an idempotency key.
  • Two-tier dedup. The durable table handles cross-restart and cross-process safety; an in-memory map single-flights same-process concurrent duplicates (:22-23, :111). The map is an optimization; correctness never depends on it, which is what makes it safe to lose on restart.

Disposition ask

  1. Does the durable-delivery contract adopt the four-outcome vocabulary (result / terminal / outcome_unknown / not_sent) as normative, given two independent implementations converged on it?
  2. On the third terminal state — is preserving config-removal-vs-restart in reason acceptable as the minimum, with permanence left to consumers? That is a strictly smaller ask than a supervisor-side permanent-absence model.
  3. Do you want the schema and the recovery-age policy as a concrete proposal on this issue, or would you rather design the daemon-side shape first and treat mine purely as evidence that the semantics are implementable?

Happy to go deeper on any of these. A defect or a gap in this floor is worth more to me than a workaround above it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions