Files
alkstore/docs/architecture/decisions/006-wake-and-delivery-contract.md
T
glm-5.3-flash 4391f6e879 docs: open Phase 1 — architecture spec set over the Phase 0 evidence
docs/architecture/ now exists: README index, overview, five component
specs (core-contract, engine-sqlite, engine-postgres, queues,
deployment), ADR-001..007 carrying the Phase 0 resolved decisions
(crate split, feature scope, per-engine drivers, dependency
ownership, wake contract, tx seam), and the centralized
open-questions tracker promotion: OQ-ST-01..08 mirror to OQ-01..08
one-to-one with statuses/resolutions carried; new Phase 1 questions
append (OQ-09 scheduler collapse, OQ-10 contract versioning).
Open Phase 1 work: OQ-04 contract pinning (high), OQ-05 queue
semantics depth (high), OQ-06 honker-core quality read (high;
fork-trigger gate), OQ-08 capability surface, OQ-09, OQ-10.

Erratum fixed in phase-0 OQ-ST-04 (thread-affinity friction is
SQLite-side, previously garbled as pg-side) and a stale scheduler-
boundary pointer corrected in consumer-inventory.md. Two review
passes run (findings: OQ-promotion numbering faithfulness, ADR
back-reference sync) — all critical/warning findings resolved.
2026-10-04 18:13:10 +00:00

5.9 KiB
Raw Blame History

ADR-006: Wake contract — opaque wake + re-read, and the notify-vs-streams delivery split

Status

Accepted (the contract skeleton the evidence supports); remaining surface pinning is OQ-04's contract work against this ADR

Context

The two engines' wake mechanisms are structurally different — SQLite wakes by a watcher polling PRAGMA data_version (deliver-on-commit, no server push), Postgres by LISTEN/NOTIFY (server push, connection-bound, no replay). A naive unification either collapses to polling behavior on the strong side or promises semantics only one engine has. Both POCs (2026-10-04) measured the pieces and — the load-bearing finding — verified that the two engines can share the same wake contract unchanged: wake is opaque, delivery is not guaranteed to be precise, and consumers re-read state.

Honker's own processing-guarantees table is the cautionary prior art: per-binding auto-checkpoint-vs-manual-save ambiguity produces different guarantees under one function name. A single-crate version must pick one answer per mechanism, not inherit the table.

Decision

The contract fixes three things:

1. The unified wake contract: opaque wake + re-read state

listen() (and stream/queue subscription wake) delivers an opaque wake signal: "something changed; re-read the state you care about." The contract never carries semantic content, ordering promises, or change descriptions. Consumers that need the actual data re-read it (the hot-path pattern the ecosystem already uses: a subscriber holding a cache invalidates on wake and re-reads, instead of re-polling).

  • SQLite: data_version watcher fires on commit; wakes coalesce under bursts (overtriggering on purpose — "one indexed SELECT is cheap; a missed wake is a correctness bug").
  • Postgres: LISTEN delivers per-notification (no coalescing), and the forwarder's synthetic reconnect-wake covers connection gaps.
  • Both: consumer code must be correct if wakes repeat, coalesce, or arrive in any order, with at-least-once wake delivery. Exactly-once processing semantics belong to queues/streams (below), never to the wake layer.

Measured ground: wake latency p50 ≈ 1.1–2.2 ms on both engines at default cadence; per-payload burst delivery verified (30/30 on pg, coalesced-but-complete re-read on SQLite).

2. Delivery guarantees, pinned per mechanism (no per-binding table)

Mechanism Durability Replay Atomicity Guarantee
notify / listen none never commit-atomic (delivers at commit; rollback drops) fire-and-forget, at-most-once per listener session
streams durable table row yes, per-consumer offset, replay-on-attach default publish is commit-atomic every committed event readable by every consumer that hasn't passed its offset
queues durable row claim/ack model enqueue is commit-atomic at-least-once work with visibility timeouts
  • The trait does not promise replay under listen() — on either engine. Durability+replay needs are what streams are for; this split is the contract's load-bearing line.
  • Listener sessions start "from now" (SQLite: the watcher's current state; Postgres: MAX(id)-equivalent at attach). History belongs to streams.
  • Wake delivery failures surface, not silence: watcher death closes subscriber channels (SQLite, honker-core's WatcherDeathGuard behavior); Postgres connection gaps surface as the synthetic reconnect-wake on a reserved channel ([ADR-004], [engine-postgres.md]).

3. Reserved names are a contract surface

The Postgres forwarder's synthetic reconnect-wake needs a reserved channel namespace no consumer channel may collide with. The naming convention for reserved/meta channels (and any internal queue/stream names) is pinned in the core contract — exact strings are OQ-04 contract work; the existence of a reserved namespace is decided here.

The caching-subscriber pattern rides the same contract

A subscriber that also holds a cache gets invalidation from the opaque wake + re-read: the contract deliberately does not carry key-level payloads, so cache clients key their invalidation on their own read-set, informed by channel names (the channel name is the one piece of semantic content listen() carries). Key-payload design, if any consumer needs richer invalidation, is a consumer-inventory-row-gated extension — not assumed now.

Consequences

Positive

  • One wake contract, verified identically on both engines — the "how does a change become visible" question alkstore exists to answer, answered once.
  • The delivery-guarantee table is pinned per mechanism, killing the per-binding ambiguity class honker's docs exhibit.
  • Consumer code branches on mechanism choice (notify vs streams vs queues), never on engine type (guiding principle 4; the honest single/multi-host line is [deployment.md]'s and OQ-08's, not this contract's).

Negative

  • The opaque wake is deliberately less informative than key-payload notify systems; consumers needing fine-grained invalidation pay a re-read or need streams.
  • Wake overtriggering on SQLite means consumers must be idempotent on wake (a documented consumer obligation, part of the contract text).

References

  • docs/research/poc-sqlite-posture-findings.md §Probe 2, §"What feeds where"; docs/research/poc-pg-posture-findings.md §Sub-module W.
  • OQ-ST-04 (docs/research/phase-0.md) — de-risked by these measurements; the contract remainder is OQ-04 + core-contract.md.
  • ADR-002 — notify/listen and streams are first-class; the guarantee split is why both exist.
  • ADR-004 — the forwarder owning the Postgres side of this contract.
  • ADR-007 — commit-atomicity's mechanism (the *_tx seam).
  • core-contract.md — the spec carrying this contract's full surface.