Events Rollout History¶
Status summary: Steps 1–3 are built and merged as of 2026-07; Step 4 remains a future optional outbox/tailer evolution.
12) Staged implementation plan¶
Step 1 — seam hardening (no wire/schema change) [built]¶
- Extract
EventLoginterface from store. ✓ - Introduce
Emitter(Emit,Signal, internalstamp/dispatchCommitted). ✓ - Route all mutation paths through
commitCard. ✓ - Add constructor usage (
CardEvent(...)initially; event-specific constructors for common mutations). ✓ - Add test fakes + seam acceptance tests. ✓ (see §10)
TakeNextusesClaimAtomic(already persists) — dispatch viaemitter.dispatchCommitted, notEmit, to avoid double-persisting. ✓
Step 2 — board scope support¶
- Add
scope/board_idfields and filtering semantics. - Add schema migration and backfill.
- Extend bus/feed query filters.
- Add board-scope tests.
Step 3 — condition event rollout¶
Rolled out seam by seam, each its own reviewable slice. Instant conditions (synchronously evaluable, inline after the triggering mutation) land first; temporal conditions (scheduler-backed) land last, after the instant machinery has validated the Signal / Emit / persisted-condition paths.
- 3a — WIP signal
[built].wip_exceeded/wip_clearedfire when a board column crosses its configured limit; ephemeralSignal; crossing-deduped so they fire only on a state change, not every mutation. - 3b — persisted-condition escalation
[built]. OneEmitter.Conditionseam routes each condition by policy: types in workspacesettings.persist_conditionsgo throughEmit(durable, replayable), the rest throughSignal. Bus/observer delivery is identical either way. See §11.2. - 3c — remaining instant conditions
[built].lane_drained/lane_refilledandcard_blocked/card_unblocked. Unified with 3a as specified:Service.evaluateColumnruns a single column census (oneListCardsper affected column per mutation) feeding both the WIP-limit crossing and, for columns inboard.monitors.alert_when_empty, the drained-lane crossing, through one shared crossing-state map (Service.condState, keyedboard\x00column\x00{wip,lane}) viaevaluateCrossing— no second parallel counting path.evaluateColumnnow fires from every column-changing mutation path:PatchCard(status move, as 3a already had),CreateCard(a card landing directly in a capped/ watched column — was a gap), andTakeNext(claim + optional status move, evaluated from the returnedstatus_changeddiff — was a gap).card_blocked/card_unblockedreuseCardQuery.Blocked's exact SQL predicate — both compile from the sharedinternal/sqlite.blockedLinkTypesINfragment via the newStore.Blockers/Store.BlockingDependents, so "blocked" has one definition, not two.evaluateBlocked(keyed per-card in the samecondStatemap) fires fromAddLink/RemoveLink(the source card's own blocked state) and from any committed status change, viareevaluateDependents, on every card depending on the card that moved (covers a target reaching "done" — unblocks — and leaving it again — re-blocks). Table-driven tests cover all instant-condition paths, a card-state-immutability assertion (§2 principle 4 — the core records, it does not act), aBlockers≡CardQuery.Blockedagreement test, and the escalated-append-failure-is-logged-not-fatal case (§8 point 7). - 3d — deadline scheduler
[built](machinery only; no condition type registered yet — see 3e). Enablers: aClockseam (core.WithClock, productionwallClockdefault,clocktest.Fakefor tests — waitable, not just readable) and thestatus_sincecolumn (additive migration, maintained byCreateCard/PatchCard/ClaimAtomic).MonitorScheduler(internal/core/monitor.go): acontainer/heapmin-heap keyed by earliest deadline; no fixed tick; sleeps until the next deadline (capped at 1h, the integration.md safety net); an empty heap parks on its wake channel alone — zero wakeups, proven by a call-counting fake clock. Deadlines are reconstructible from denormalized card state (e.g.status_since) via a per-typerebuildcallback; nothing is persisted for the heap itself — only the fired-marker (condition_markstable,INSERT OR IGNORE= atomic check-and-set, pruned to the latest key per (card, type) on a fresh fire) is durable, giving exactly-once even across a restart (tested: two scheduler instances over the same store, the second's rebuild skips the already-fired key). Lazy / refcounted:InProcBusgainedSetOnSubscriptionChange(fired on subscribe, unsubscribe, and the slow-consumer drop insidePublish— a lost consumer never callsUnsubscribeitself) andHasSubscriberFor; a type arms iffbus.HasSubscriberFor(t) || emitter.IsPersisted(t)— a type listed in workspacesettings.persist_conditionsis a permanent consumer (armed with zero subscribers), exactly as specified. A serverless CLI process (no subscribers, nothing persisted) never arms and the scheduler goroutine never does real work. Live-verified against the real demo workspace with a synthetic condition type and the real wall clock (not just the fake). - 3e — temporal conditions
[built].status_timeout/card_idlewired onto the 3d scheduler viaService.monitorObserver, anEventObserver— zero new call sites in the mutation paths.status_changed/card_createdarmstatus_timeoutatstatus_since + max; every durable card-mutation event re-armscard_idleatupdated_at + idle_after— a condition event itself never resets it (isConditionTypeguards this; test-pinned: firingwip_exceededalongside real mutations must not push back a card's idle deadline). Fire-time re-verify checks identity (status+status_sincefor timeout,updated_atfor idle) so a stale deadline discards silently instead of firing on outdated state. The mandated integration test (real SSE client + injected clock,internal/httpapi/temporal_test.go) confirms the full contract: arms on a live subscriber, fires exactly once at the deadline, no duplicate on a further advance, disarms on disconnect (a second card's breach is never observed with nobody listening), and a fresh subscriber's rebuild fires that still-true breach exactly once on reconnect. Board config:monitors.max_time_in_status/idle_after(duration strings via the newParseMonitorDuration, which adds a"d"days suffixtime.ParseDurationlacks). Live-verified against a scratch demo copy with a 2-second override: the rebuild path correctly enumerated and fired every real backlog card already sitting inreview, and a newly created card fired precisely at its own deadline./v1/breachesdoes not yet include temporal items — deferred (noted in design-notes.md), not required by this milestone.
Cross-cutting hardening — folded into 3c's first PR, all [built]:
- Board-membership caveat. Condition census counts by type membership
(
TypeIDIn); a board defined by aDefaultFilter(e.g.hipri) is not counted correctly, and the census caps at 500 cards. Documented onevaluateColumn; fix only if/when filter-defined boards gain WIP or lane limits. - Config validation.
internal/config.validatePersistConditionschecks eachpersist_conditionsentry againstcore.ConditionTypes()at load and appends a warning (config.Result.Warnings, printed bycmd/cardsviaopenWorkspace) for unknown types — a typo ("wip_exceded") now surfaces instead of silently no-op'ing.monitors.alert_when_emptyunknown columns hard-fail at load, matching the existing board-validation convention. - Append-error surfacing.
evaluateCrossinglogs a failed escalated append (log.Printf("ERROR: escalated condition append failed ...")) instead of discardingCondition's return value (see §8, point 7). - Rename (cosmetic).
dispatchCommitted→dispatch— it always served both the durable and the ephemeral path; the old name implied commit.
Dogfood. 3b is merged — set persist_conditions: ["wip_exceeded"]
on the demo workspace so we exercise the escalation path the same way integrators
(picraft) will — otherwise signals are invisible after the fact and can't be
dogfooded ("did WIP fire yesterday?" is unanswerable for an un-escalated signal).
Step 4 — optional outbox/tailer evolution [future]¶
If synchronous post-commit dispatch becomes insufficient, make the durable log itself the delivery source:
request transaction -> card rows + event rows
background tailer -> reads log in id order -> bus/SSE/observers/projections
consumers -> track durable cursors
Benefits: - closes the commit-then-crash-before-dispatch live-delivery gap - isolates subscriber/observer backpressure from request latency - gives projections and integrations a durable cursor model
Costs: - adds a worker/tailer and cursor bookkeeping - live delivery becomes slightly asynchronous - more operational surface
This is deliberately staged as an evolution, not Step 1. The current design is acceptable while live bus/observer delivery is best-effort and feed recovery is the correctness path.
13) Why this revision is simpler¶
- Keeps one seam for consistency, but draws a hard line between durable facts and ephemeral signals.
- Keeps
dispatchCommittedinternal so commit-before-dispatch is enforced by API shape rather than convention. - Makes failure behavior explicit, including the synchronous dispatch crash gap.
- Uses conservative delivery language (idempotent consumers + cursor recovery).
- Treats event payloads as versioned contracts while keeping the envelope simple.
- Prioritizes test seams, golden fixtures, and shift-left checks over additional framework surface.
In short: small interfaces, explicit semantics, durable correctness, and pragmatic evolution path.