Skip to content

TLA+ protocol expansion for Where - #184

Open
kyleve wants to merge 14 commits into
mainfrom
cursor/tla-protocol-expansion
Open

TLA+ protocol expansion for Where#184
kyleve wants to merge 14 commits into
mainfrom
cursor/tla-protocol-expansion

Conversation

@kyleve

@kyleve kyleve commented Aug 4, 2026

Copy link
Copy Markdown
Owner

Expands the TLA+ verification pilot from one spec to eight, adds a unified ./tla-check runner, and lands the two production fixes the models falsified.

Tooling

  • Root ./tla-check discovers Where/Specifications/*/manifest.json, pins TLC + Temurin, and runs pass/fail cases
  • TrackingReconciliation migrated off its local check script

TLA+ specs (all green via ./tla-check)

Spec Verdict
TrackingReconciliation Coalesced worker design verified; broken config reproduces the toggle race
IntentServicesHandoff Handoff-not-factory contract verified
IngestorQuiesce Quiesce-before-wipe ordering verified
LogRouting At-most-one durable sink verified
PostWriteReconcile Canonical post-write fan-out verified; broken config allows premature changes()
StorePerformSerialization Confirmatory — matches existing SwiftDataStoreTests
LaunchLifecycle Confirmatory — undetermined promotion + memo-preserving re-drive
ScopeExclusivity Confirmatory — at-most-one active scope / live real container

Production fixes

  1. Tracking toggle race — coalesced worker on WhereSession (found by TrackingReconciliation broken config)
  2. Ingest fan-out gapDayJournal.ingest / bulk ingest / addManualSample now call reconcileAfterDayChange() (found by PostWriteReconcile negative control + code review)

Docs

  • Where/TODOs.md — tracking + ingest items closed; PostWriteReconcile cross-links; P2 TLA items landed
  • Where/AGENTS.md./tla-check pointer under Testing

Checks

  • ./swiftformat --lint
  • ./tla-check (8 specs)
  • WhereSessionTrackingTests, DayJournalTests, IntentServicesTests, SwiftDataStoreTests
Open in Web Open in Cursor 

kyleve and others added 9 commits August 4, 2026 12:39
Extract a root script that discovers specs via manifest.json, pins TLC and
Temurin through mise, and runs pass/fail cases. TrackingReconciliation drops
its local check script in favor of the shared runner.

Co-authored-by: Cursor <cursoragent@cursor.com>
Models the handoff-not-factory contract for App Intent service installation:
parked callers await install, clear resumes on the next install, and a later
install replaces the cached stack. Cited by IntentServicesTests.

Co-authored-by: Cursor <cursoragent@cursor.com>
Serialize ingestor start/stop on a single worker lane, re-read intent after
each await, and let stop preempt an in-flight start without deadlocking.
LocationIngestor.start() bails out if stop() ran during LocationSource.start().
newerStopWinsOverInFlightStart passes without withKnownIssue.

Co-authored-by: Cursor <cursoragent@cursor.com>
Models reset/erase teardown ordering: quiesce completes before the store wipe
and a late GPS sample cannot repopulate an erased store.

Co-authored-by: Cursor <cursoragent@cursor.com>
Models at-most-one active durable log sink per process and scope activation
handoff, covering the Flyover sibling-scope exception tracked in TODOs.

Co-authored-by: Cursor <cursoragent@cursor.com>
DayJournal.ingest, bulk ingest, and addManualSample now call
reconcileAfterDayChange() so reminders and widgets stay in sync. TLC model
verifies the canonical post-write ordering; tests updated for the extra
reconcile on ingest paths.

Co-authored-by: Cursor <cursoragent@cursor.com>
Confirmatory model for at-most-one outermost perform, same-task nesting, and
FIFO waiter ordering. SwiftDataStoreTests cites the AtMostOneOutermost property.

Co-authored-by: Cursor <cursoragent@cursor.com>
Add clearWhileParkedResumesOnTheNextInstall, cited by the IntentServicesHandoff
TLA+ spec's parked-install contract.

Co-authored-by: Cursor <cursoragent@cursor.com>
Mark tracking worker and ingest fan-out fixes done, cross-link PostWriteReconcile
from remaining fan-out gaps, file P2 LaunchLifecycle and scope-exclusion models,
and add ./tla-check pointer under Testing.

Co-authored-by: Cursor <cursoragent@cursor.com>
guard !isMonitoring else { return }
isMonitoring = true
await locationSource.start()
guard isMonitoring else { return }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Fix: abort start if stop ran during the await (supporting fix for TrackingReconciliation)

What was wrong: start() set isMonitoring = true before await locationSource.start(). A coalesced stop could interleave on the ingestor actor (stop() sets isMonitoring = false), but when the parked start() resumed it continued setting up the ingest task as though monitoring were still wanted.

How the model exposed it: TLC's counterexample has disable stop the ingestor (ingestorActive = false) while enable is still in its "starting" phase. Production needed the same guarantee: a stop during the LocationSource.start() await must leave the ingestor genuinely off, not half-started.

What this guard does: If stop() ran during the await, bail before backlog drain / ingest-task setup. Pairs with the worker's immediate ingestor.stop() on coalesced disable.

}

let currentEffective = wantsTracking && authorizationStatus.allowsBackgroundTracking
guard currentEffective == targetEffective, !trackingReconcilePending else {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Fix: re-read intent before publishing (TLA: TrackingReconciliation, FixedCompleteStart / FixedCompleteStop)

What was wrong: The old path published effective tracking state from the target captured before the await, not from intent after the side effect completed.

How TLC found it: In the coalesced model, FixedCompleteStart sets worker' = IF desired /\ Authorized = target THEN "idle" ELSE "ready" — i.e. after start completes, if intent changed during the "starting" phase, the worker loops rather than publishing stale published = TRUE. The broken model's BrokenCompleteStart always sets published' = TRUE regardless of current desired, which is exactly the bug TLC falsifies.

What this code does: After each ingestor.start() / stop() await, recompute currentEffective and continue the loop if it no longer matches the target captured for that iteration (or if another reconcile was coalesced via trackingReconcilePending). Only then assign isTracking.

public func ingest(_ sample: LocationSample) async throws {
try await store.perform { try await store.add(sample: sample) }
await widgets.publishAfterIngest(of: sample)
await reconcileAfterDayChange()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Fix: route ingest paths through full post-write fan-out (TLA: PostWriteReconcile)

What was wrong: ingest(_:), bulk ingest, and addManualSample called widgets.publish() directly, skipping reconcileIssueState() — so a GPS presence change updated the widget snapshot but left the reminder badge and issue alerts stale until the next foreground reconcile.

How TLC found it: The model splits commit, reconcile phases, and changes() ping. Property NoChangesBeforeReconcileDone requires changesPinged => reconcilePhase = done on the canonical path. Broken.cfg allows PingChanges before reconcile completes (BrokenNoEarlyPing), modeling write paths that notify readers before side effects are honest. Code review against the model's correspondence table showed ingest was a production instance of a partial fan-out — widget-only, not the unified reconcileAfterDayChange() sequence the canonical manual-day path uses.

What this change does: All three ingest entry points now await the same fan-out as manual-day writes (invalidate → reminders → issue alerts → widgets). Remaining gaps (DailySummaryReconciler, setPrimaryRegions) are cross-linked in Where/TODOs.md as out-of-model until routed.


/// Apply ingestor start/stop and publish ``isTracking`` on a single lane so
/// a newer stop cannot lose to an older start that resumes after its await.
private func runTrackingReconcile() async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Posted by an AI agent on kve's behalf.

Fix: coalesced tracking worker (TLA: TrackingReconciliation)

What was wrong: Each toggle assignment spawned an independent Task. reconcileTracking() captured intent, awaited ingestor.start(), then unconditionally published isTracking = true when the await returned — even if a newer stop had already persisted wantsTracking = false and stopped the ingestor during that await.

How TLC found it: Broken.cfg runs the command sequence enable → disable with weak fairness. TLC violates CorrectAtQuiescence with final state desired = persisted = ingestorActive = false but published = true. The counterexample matches the real actor boundary: enable enters LocationIngestor.start(), disable runs during the LocationSource.start() await, then the stale enable completion publishes true.

What this code does: One serialized worker lane; on coalesced stop, call ingestor.stop() immediately instead of awaiting the in-flight start (avoids deadlock when start is parked on LocationSource.start()). Coalesced.cfg satisfies the same property for the modeled design.

kyleve and others added 2 commits August 4, 2026 12:51
LaunchLifecycle models undetermined promotion, memo-preserving re-drive, and
foreground-only capture-today. ScopeExclusivity models at-most-one active
scope and live real container (complementing LogRouting sink ownership).
Both confirmatory on Current.cfg; ./tla-check now runs eight specs.

Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
@cursor

cursor Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Posted by an AI agent on kve's behalf.

Overall, this is an unusually disciplined adoption for an app codebase — closer to how teams use TLA+ for design verification than as a research artifact. The pilot → expansion arc worked: one spec found a real bug, the skill codifies the workflow, and ./tla-check makes eight specs runnable without polluting the main toolchain.

Below is an assessment against both what you've built and what the broader TLA+ community recommends.


What's working well

1. Narrow scope, one question per spec

Each folder asks a single correctness question (CorrectAtQuiescence, NoOverlappingRealContainers, MemoNoDoubleRun, etc.). That matches the consensus from practitioners (including Mergify's merge-queue writeup): the hard part is deciding what to leave out, not writing TLA+ syntax. Your READMEs document exclusions explicitly — that's the right instinct.

2. The skill is excellent process documentation

The tla-verify-protocol skill reads like a distilled version of Leslie Lamport's "specify before you implement" philosophy, adapted for Swift/async:

  • Separate desired / persisted / in-flight / published state (matches your TrackingReconciliation model and the skill's modeling checklist)
  • Split actions at every await boundary
  • Three verdicts only: falsified, verified-for-bounds, inconclusive — no overclaiming
  • Explicit anti-vacuity guidance (negative controls, don't erase faithful counterexamples)

That's better than most public "TLA+ in CI" tutorials, which stop at "run TLC and go green."

3. Negative controls are first-class

Every spec has a Broken.cfg that must fail for the expected reason, checked via manifest.json + outputContains. This directly addresses the dead-invariant problem Mergify hit: a green TLC run you can't break on demand is just silence. Your manifest pattern enforces that mechanically.

4. Traceability back to Swift

The loop is closed properly:

TLA counterexample → deterministic Swift test → fix → spec updated → test passes

newerStopWinsOverInFlightStart holding at the modeled await is exactly the pattern experts recommend. Property names (AtMostOneOutermost, MemoNoDoubleRun) cited in tests and READMEs make the evidence durable.

5. ./tla-check is the right abstraction

  • Pinned TLC + JDK with checksum verification
  • Per-spec manifest.json (declarative cases, pass/fail expectations)
  • Isolated under .build/tla/, opt-in, no root .mise.toml pollution
  • Artifact logs per run for counterexample inspection

This is cleaner than most ad-hoc java -jar tla2tools.jar CI snippets you see online.

6. Opt-in, not CI (for now) is a reasonable call

Mergify explicitly notes spec drift as a maintenance burden and that CI without drift discipline gives false confidence. Your AGENTS.md says "opt-in, not CI" — that's honest. Formal methods communities generally agree TLA+ complements testing; it doesn't replace it (wal.sh TLA+ guide, Lamport's own framing).

7. Confirmatory specs still have value

Even "green on Current.cfg" specs document a claim with explicit bounds. That's useful for onboarding and for the controller rewrite TODOs — you've pre-modeled slices before the big refactor.

8. Protocol pattern library

references/protocol-patterns.md (serialized worker, generation token, queue drain, handoff-not-factory) maps directly to your actual bugs. That's a reusable pattern catalog, not one-off prose.


Possible improvements

High value, low disruption

Improvement Why
Record TLC stats in READMEs Each run log already has "N distinct states generated." Pinning approximate counts (e.g. "~25 states, depth 7") gives drift signal — if a small model suddenly explodes to 10k states after a code change, something's wrong.
Vacuity lint in ./tla-check Parse TLC output for "N states generated" on broken cases; warn if broken case explores <5 states (might not be exercising the property). Optionally grep for invariants never mentioned in violation traces.
SANY-only parse check The TLA+ Examples repo CI runs SANY (syntax parse) on all modules before model checking. A lightweight ./tla-check --parse or pre-check in the script would catch syntax errors without a full TLC run.
Stutter/terminal states (you already learned this) LaunchLifecycle and ScopeExclusivity needed explicit Stutter actions to avoid deadlock false positives. Worth adding to the skill: "terminal completion needs a stuttering action or DEADLOCK is a failure."
Cross-link specs that compose ScopeExclusivity complements LogRouting; LaunchLifecycle relates to IngestorQuiesce. A short "Related specs" section in each README would help navigability as the catalog grows.
PR checklist hook When a PR touches modeled code (WhereSession, LifecycleRunner, WhereScope, DayJournal), AGENTS/skill could say: "check whether the corresponding spec README still matches." No CI required — just a human/agent gate.

Medium value, more effort

Improvement Why
Optional CI job (path-filtered) Industry pattern: run TLC only on Where/Specifications/** changes (GitHub Actions example). Cheap on Linux (~1–2 min for your small models). Makes PR #184's specs regression-proof without macOS/Xcode. You deliberately deferred this — still the natural next step when drift discipline is in place.
PlusCal for larger models Your .tla files are hand-written and small — fine for now. If LaunchLifecycle grows to cover gates, detached steps, and teardown, PlusCal (algorithmic syntax → TLA+) is easier to maintain. The skill could note: "reach for PlusCal when the action count exceeds ~10 or you need labeled atomic regions."
Liveness properties (sparingly) Most specs are safety-only (CorrectAtQuiescence, etc.). TrackingReconciliation has EventuallySettled with weak fairness — good. For handoff specs (IntentServicesHandoff), a bounded liveness claim ("parked waiter eventually resumes on install") would strengthen the model, but only with explicit fairness tied to real runtime guarantees (the skill already warns about this).
Trace rendering helper TLC counterexamples are readable but dense. A small script that formats the violation trace into a markdown timeline (action → production counterpart) would speed up the "translate evidence back to software" step the skill describes.
Scaffolding command ./tla-check --new SpecName generating folder skeleton (.tla, Current.cfg, Broken.cfg, manifest.json, README template) would lower the cost of spec #9+.

Lower priority / situational

Improvement Why
Apalache Symbolic model checker; handles larger state spaces than TLC. Overkill for your current small bounded models, but relevant if you model full launch plans or CloudKit sync. andyscott/rules_tla shows Apalache in CI for counter examples.
TLAPS / proof mode For infinite-state or parametric claims. Not needed while you're checking small finite models.
TLC Toolbox / VS Code extension Better counterexample visualization than log files. Developer ergonomics, not correctness.
tla-connect-style replay Rust ecosystem has trace replay against implementations. No mature Swift equivalent; your deterministic Swift tests are the right substitute.
Root-level AGENTS.md pointer TLA guidance lives in Where/AGENTS + the skill. A one-liner in root AGENTS under a "Formal verification" bullet would help agents discover it without reading Where docs first.

On the skill specifically

Strengths:

  • Correct trigger guard ("explicit TLA+ request only" — avoids agents reaching for TLA+ on every concurrency question)
  • Worked example pointer (TrackingReconciliation) without mandating copy-paste
  • "Do not implement the fix unless asked" — keeps verification separate from product work
  • Modeling checklist + protocol patterns are genuinely useful reference material

Gaps worth filling:

  1. When TLA+ is the wrong tool — one paragraph with examples from your codebase (pure UI layout, CRUD without temporal behavior, already-covered-by-actor-isolation cases)
  2. Deadlock vs completion — document the Stutter pattern after LaunchLifecycle/ScopeExclusivity taught it
  3. Confirmatory spec template — explicit workflow for "code already correct, model documents the claim" vs "model finds bug first"
  4. Maintenance contract — "when you change X, update spec Y and re-run ./tla-check" (the drift problem Mergify flags as their biggest long-term risk)
  5. Implementation constant pattern — document Implementation \in {"current", "broken"} as the standard negative-control switch (you use it everywhere but it's not named in the skill)

Bottom line

You've built something most teams never get past the pilot on: a repeatable, honest, bounded formal verification loop integrated into agent workflow without pretending it proves correctness of the whole app.

The integration is strong on:

  • Process (skill + checklist + verdict language)
  • Mechanics (./tla-check + manifest + negative controls)
  • Evidence chain (TLA → test → fix)

The main risks going forward are the ones the literature warns about too:

  1. Spec drift — green TLC on a stale model is worse than no TLC
  2. Vacuous properties — you've guarded against this with broken configs, but worth automating suspicion
  3. Scope creep — resist modeling whole WhereLaunch or full CloudKit sync in one spec; keep the narrow-slice discipline

If you want one next step with the best ROI: a path-filtered Linux CI job running ./tla-check on spec changes only. It's cheap, matches industry practice, and closes the drift loop without burdening every iOS build. Everything else can stay opt-in until the catalog stabilizes.

Route single-sample ingest and addManualSample through reconcileIssueState()
plus publishAfterIngest(of:) so reminders/issue alerts reconcile without
unconditional widget rebuilds. Bulk ingest keeps reconcileAfterDayChange().

Fixes WhereServicesTests.redundantGPSSamplesSkipRepublishingButNewRegionsStillPublish
on CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cursor

cursor Bot commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

Posted by an AI agent on kyleve's behalf.

Specula vs our TLA+ setup — overlap assessment

Thinking out loud on how much of our custom skill/tooling could be replaced by Specula.

Short answer: Specula could replace part of the agent workflow and TLC ergonomics, but not most of what we built. The overlap is real; the philosophies differ enough that a full swap would trade something we want (small, committed, Swift-native design artifacts) for something Specula is optimized for (autonomous, whole-system bug hunting with external outputs).

What we built vs what Specula is

Layer Stuff today Specula
Trigger Explicit only (tla-verify-protocol skill) Full auto pipeline (specula run … --artifact=…) or 5-phase interactive flow
Scope One narrow protocol question per spec Scenario-driven exploration across a codebase
Artifacts Committed under Where/Specifications/<Concern>/ Ephemeral .specula-output/ / runs/
TLC runner ./tla-check + manifest.json pass/fail cases TLC via MCP + pipeline scripts
Code link README correspondence tables → Swift files/lines file:line annotations + optional trace harness
Validation loop Broken.cfg negative control → fix → Current.cfg Trace replay + MC hunt configs + bug confirmation tiers
Tests Hand-written Swift Testing guards at modeled interleavings Auto-generated repro tests in repro/

Our setup is closer to maintained design experiments tied to production. Specula is closer to autonomous bug factory for concurrent/distributed systems.

Overlap: what Specula could replace

1. Agent methodology (~30–40%)

Our skill and Specula's spec_generation, tla-checking-workflow, and validation-workflow skills cover similar ground:

  • Split actions at concurrency boundaries
  • Define invariants before judging the design
  • Run negative controls
  • Read counterexamples, don't trust exit codes alone
  • Map traces back to source

Specula's guides are richer on orchestration (BFS vs simulation, fault injection, trace validation). Ours are richer on Swift-specific modeling (actors, awaits, desired vs persisted vs published, coalescing workers) and repo policy (don't wire into CI unless asked, narrow slices only).

2. TLC execution (~70–80% mechanically, ~0% politically)

Both pin Java + tla2tools.jar. Specula's MCP tools (get_tlc_summary, get_tlc_state, compare_tlc_states) are nicer for interactive counterexample debugging than reading raw TLC logs.

But ./tla-check is not just "run TLC" — it's:

  • Discoverable specs via manifest.json
  • Expected pass/fail per config (Broken.cfg must fail)
  • Pinned checksums in-repo
  • Isolated runs under .build/tla/
  • One command for humans and agents: ./tla-check PostWriteReconcile

Specula doesn't give us that repo-native contract. We'd still want ./tla-check (or equivalent) for committed specs.

3. Counterexample → test (~50%)

Specula Phase 4 (bug-confirmation) automates "write and run a repro test." We already do this manually and well — e.g. TrackingReconciliationWhereSessionTrackingTests holding the exact await. For Swift/iOS, our hand-written guards are probably more reliable than Specula's generic harness path.

What Specula adds that we don't have (and probably shouldn't replace our stuff with)

Specula phase Value Fit for Where
Code analysis / bug archaeology Mines git history, groups scenarios Useful for finding next spec candidates
Harness generation Instruments code, emits NDJSON traces High cost for Swift/Tuist/Xcode; targets Go/Rust/C++ case studies
Trace validation (Trace.tla) Proves spec matches recorded execution Overkill for 20-line protocol slices; great for raft/consensus
MC hunt + fault injection Systematic scenario search Our specs are intentionally bounded, not whole-system
Auto pipeline + scheduler Unattended multi-hour runs Needs 32GB RAM, separate checkout, frontier model access

That's ~60% of Specula's surface area — optimized for CometBFT-scale targets, not "does trackingEnabled coalesce correctly across three entry points?"

What Specula would not replace (keep these)

  1. Where/Specifications/*/ as living docs — correspondence tables, explicit exclusions, verdicts (Falsified vs confirmatory). Specula outputs aren't designed to be merged as module docs.
  2. Narrow-slice discipline — one correctness question, explicit bounds. Specula's default is broader exploration; we'd fight it to stay narrow.
  3. Swift/async conventions — our protocol-patterns.md (coalescing worker, generation token, queue drain) is tuned to this codebase.
  4. ./tla-check + manifest contract — reproducible, opt-in, no Specula checkout required.
  5. Integration with Where/AGENTS.md / TODOs.md — Specula doesn't know our layering or "core behavior in model, not views" rules.

Practical synthesis

Reasonable adoption tiers:

Tier Effort Benefit
Cherry-pick MCP tools only Low Better counterexample inspection during skill runs; keep ./tla-check
Borrow Specula skill guides Low Strengthen "Challenge the model" / hunting sections without adopting pipeline
Run Specula externally for discovery Medium specula run where --artifact=… to propose candidates; still curate into Where/Specifications/
Full Specula in-repo High, poor fit Duplicates skill + runner, fights conventions, heavy iOS harness cost

Bottom line

  • Replace entirely? No — maybe 25–35% of the process (model-check workflow, TLC debugging UX), almost none of the artifacts (specs, manifests, READMEs, Swift tests, repo policy).
  • Complement well? Yes — Specula shines at "what should we model next?" and deep autonomous hunting on unfamiliar concurrent subsystems. Our stack shines at "we know this seam is tricky; keep a falsifiable, maintained model next to the code."

The TrackingReconciliation → PostWriteReconcile arc is the argument for keeping our approach: hand-curated slices found real bugs, negative controls caught regressions (ee3eaa27), and the specs remain readable design records. Specula would likely have found similar issues eventually, but with more noise, more infra, and outputs that don't naturally live where we want them.

Highest-ROI probe without committing: install Specula's MCP servers, run ./tla-check as today, and use get_tlc_state / compare_tlc_states when reading counterexamples — zero change to repo layout.

Add a developmentTools generator source type for pinned non-SPM tooling,
wire TLA+ Tools v1.7.4 through .agents/development-tools.json, and
regenerate Where's attribution report.

Co-authored-by: Cursor <cursoragent@cursor.com>
}
}
await widgets.publish()
await reconcileAfterDayChange()

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wonder if reconcileAfterDayChange is the best name for this, vs something else like reconcileAndPublishWidgets?

public func stopTracking() async {
wantsTracking = false
await services.ingestor.stop()
isTracking = false

@kyleve kyleve Aug 4, 2026

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks like we dropped isTracking = false; confirming this was intentional, right? I think trackingWorkerLoop handles this now?

Clarifies the method runs after persisted day-level store data changes,
not a calendar-day rollover.

Co-authored-by: Cursor <cursoragent@cursor.com>
@cursor

cursor Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Posted by an AI agent on kve's behalf.

Isolating declarative behavior from async structure

The TLA specs read well because they separate:

state variables  →  what the world looks like
actions          →  atomic steps (each = one interleaving point)
invariants       →  "at quiescence, these agree"

Production code still often looks like:

read state → do async thing → maybe re-read → maybe loop → publish

That's necessary at the edges, but the decision logic (should we publish?, which fan-out?, did intent change during the await?) is what we want to digest — and what maps 1:1 to the specs.


The pattern across the areas we touched

Three recurring shapes:

Area Declarative core Async shell today
Tracking desired, effective = intent ∧ auth, worker phase, "publish only if target still matches" trackingWorkerTask, runTrackingReconcile, while continue
Ingestor start/stop isMonitoring flag, "stop during await → abort setup" locationSource.start() await, task creation
Post-write reconcile write → fan-out sequence → ping; sample vs day-data widget policy store.perform, sequential await reconcile*

The specs already name the declarative layer. The opportunity is to make that layer explicit in Swift, not buried inside loops.


Direction 1: Pure protocol types (closest to TLA)

Extract a small state machine value type per protocol — no async, no side effects:

// Illustrative — not proposing exact API yet
struct TrackingReconcileState: Equatable {
    var desired: Bool
    var authorizationAllowsBackground: Bool
    var ingestorActive: Bool
    var published: Bool
    var worker: WorkerPhase  // idle | ready | starting | stopping
    var target: Bool
    var pending: Bool
}

enum TrackingReconcile {
    static func effectiveTracking(desired: Bool, auth: Bool) -> Bool {
        desired && auth
    }

    /// After a side effect completes: may we publish?
    static func shouldPublish(
        target: Bool,
        currentEffective: Bool,
        pending: Bool,
    ) -> Bool {
        currentEffective == target && !pending
    }

    /// Coalesced stop while worker is in-flight
    static func shouldPreemptInFlightStop(targetEffective: Bool) -> Bool {
        !targetEffective
    }
}

WhereSession becomes: read inputs → call pure functions → execute the chosen effect (start ingestor, stop ingestor, set isTracking).

Benefits:

  • Reads like the README correspondence table
  • Unit-testable without Task or ingestor fakes
  • TLA mapping stays obvious: each pure function ≈ one action's guard + update

Cost: another type/file per protocol; need discipline to keep effects at the boundary.


Direction 2: Reusable coalescing worker (tracking generalized)

Tracking is the serialized/coalescing worker pattern from the skill — worth extracting once:

protocol CoalescedWorkerEffect {
    associatedtype Target: Equatable
    func apply(_ target: Target) async
    func readCurrentEffective() -> Target  // after await
}

// Generic loop: capture target, apply, re-read, continue if stale

WhereSession supplies Target = Bool (effective tracking) and wires ingestor start/stop.

Benefits:

  • The while true { … continue } / pending / preempt-on-stop logic lives in one place
  • Next protocol with the same shape (handoff, launch drive?) reuses it
  • TLA spec for "coalesced worker" maps to the generic type, not WhereSession

Risk: over-abstraction if only tracking needs it today. A lighter version: CoalescedWorkerLoop as a package-private helper in WhereCore with a closure-based API, not a full generic framework.


Direction 3: Reconcile as a declarative plan (DayJournal)

PostWriteReconcile is really a fixed pipeline with a policy fork:

always:  invalidate → reminders → issueAlerts
then:    widgets.fullPublish  |  widgets.afterIngest(sample)

Today that's two methods duplicating the shared prefix. A more declarative shape:

enum PostWriteOutcome {
    case sampleIngest(LocationSample)
    case dayDataChanged
    case issueOnly  // dismiss/restore
}

struct PostWriteReconcilePlan: Equatable {
    let outcome: PostWriteOutcome
    var steps: [ReconcileStep] {  }  // pure derivation
}

enum ReconcileStep {
    case invalidateIssues
    case reconcileReminders
    case reconcileIssueAlerts
    case publishWidgets
    case publishWidgetsAfterIngest(LocationSample)
}

DayJournal becomes: commit write → let plan = PostWriteReconcilePlan.for(outcome) → execute plan sequentially.

Benefits:

  • The TLA reconcilePhase enum maps directly
  • Adding DailySummaryReconciler is editing the plan builder, not N call sites
  • Tests assert plan(for: bulkIngest) == [.invalidate, …, .publishWidgets] without mocking WidgetKit

Direction 4: Split "atomic regions" explicitly (Ingestor)

LocationIngestor.start() is really:

  1. Sync preambleisMonitoring = true
  2. Await boundarylocationSource.start()
  3. Sync postamble — guard, backlog, task creation

The TLA bug was at boundary 2→3. Making regions explicit helps:

enum StartDecision {
    case abort  // stop won during await
    case completeSetup
}

static func afterLocationSourceStart(isMonitoring: Bool) -> StartDecision {
    isMonitoring ? .completeSetup : .abort
}

The actor method stays async, but the only branch that matters for the protocol is a one-liner pure function — same as TLC's guard.


Direction 5: Keep specs and code structurally parallel

We already have correspondence tables in READMEs. Push that one step further:

  • Property names in Swift — e.g. TrackingReconcile.shouldPublish doc-linked to CorrectAtQuiescence
  • Test names mirror TLA — we do this (newerStopWinsOverInFlightStart); extend to plan tests for DayJournal
  • Optional: hand-maintained "spec snapshot" tests — pure Swift tests that assert the same invariants as TLC on tiny finite inputs (not a full model checker, but catches mapping drift)

The goal isn't duplication of TLC — it's one declarative layer both TLC and Swift talk about.


What I'd prioritize (ROI vs complexity)

High ROI first:

  1. Tracking pure functions — small diff, directly addresses the bug class, tests become trivial
  2. PostWriteReconcilePlan enum for DayJournal fan-out — clarifies the sample vs day-data split we just fought CI over

Medium — when a second consumer appears:
3. Shared coalescing worker helper

Later / situational:
4. Generic effect interpreter
5. LaunchLifecycle as explicit state type (better after the controller rewrite; specs are confirmatory placeholders today)


Guardrails (fit with our conventions)

  • Pure logic in WhereCore, async orchestration stays on actors (DayJournal, LocationIngestor) or view-models (WhereSession) — matches layering rules
  • No second copy of behavior in views — declarative types are inputs to Core/Session, not SwiftUI
  • Don't replace TLA — the pure Swift layer is the human-readable digest; TLC still explores interleavings we won't enumerate in tests
  • Keep slices narrow — one protocol type per spec concern, not a mega WhereProtocolState

Natural next steps if we want to pursue this: sketch TrackingReconcile as a real WhereCore type (with tests mirroring Coalesced.cfg), or sketch PostWriteReconcilePlan and see how many DayJournal call sites collapse.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant