Skip to content
C.W.K.
Lesson 05 of 06 · published

Trips by Date, and Pointers Never Copies

~14 min · trips, references, deep-links, family-contracts

Level 0First Word
0 XP0/47 lessons0/13 achievements
0/100 XP to next level100 XP to go0% complete

A trip is a name and two dates

A trip in Spark is small on purpose: a name, an optional start and end date, and a note. It is made on the web, and the phone lists trips without making them. Take an invented one, Kyoto 2027, running from 2027-04-02 to 2027-04-12.

Moments gather into it two ways, and the first code block holds both. At creation, a moment with no trip of its own asks trip_on for the trip whose dates contain its local date. If exactly one does, the moment joins it. A photo whose capture truth says 2027-04-03 joins Kyoto 2027 the moment it is recorded, and the comment beside that call gives the reason: the trip becomes part of my context from the first turn. Track 3 showed that context. When the engine looks for what Dad already met that bears on a new moment, one of its two tests is the same trip.

The second way is the trip page. trip_moments takes every moment assigned to the trip, and also every unassigned moment whose local date falls inside its dates. Say the trip is only created on its third day. The moments of the first two days were recorded with no trip, but the page still gathers them, oldest first. The test calls this older days joining by date.

The two ways differ in one honest detail. A day that joined only by date shows on the page, but it has no trip id, so a new turn's context does not count it as the same trip. And if two trips' dates overlap, a capture on a shared day joins neither at creation, because trip_on answers only when exactly one trip fits. Both pages still show it, through the date rule.

Dates are the phone's local dates, from capture truth. A sign photographed late in the evening belongs to the day it was taken where it was taken, whatever clock the server keeps.

What a trip page gathers

The page is one read. It returns the trip, its moments with their results, the phrases met (expressions, answers, cards and relays, one per text, each with the situation it was met in), the roots named on those moments, and a point for every moment that has a location. The web draws those points with MapLibre over OpenFreeMap's dark style, the same map Waystone uses. The phone uses MapKit, and a trip with a single point opens at city scale rather than street scale.

The architecture also gives every trip a conversation of its own in cwkPippa, spark:trip:<id>. That is design, not yet built. A trip id is a ULID, so cwkPippa's binder would accept one, but nothing in Spark opens a trip thread; the only thread the engine opens belongs to a moment. Lesson 2 has the same gap for roots.

Pointers, never copies

A moment often happens beside something another family app owns: a Waystone crumb from the same walk, a Vesta crumb in a journal. Spark links to it and keeps nothing else. The family contract for this, SIBLING-CONTRACTS section 16, is a reference of four fields: app, kind, id, and parent when the id is unique only inside a container. A Waystone crumb needs its journey; a Vesta crumb's id is unique across journals and survives a move, so it carries none. Spark is an owner too: another app can point at a Spark moment the same way.

The link itself is tiny. Its id is link:, the moment id, and the reference's key, so linking the same item to the same moment twice re-activates one link. Beside the reference it keeps one thing: the label Dad saw when he linked it, cut to 200 characters, shown only while the owner does not answer. Everything else is asked of the owner every time the link is shown.

The second code block is that asking. resolve calls the owner's one-item route and returns one of three statuses, and they are kept apart on purpose:

  • found, with the owner's answer folded into one card shape, so a Waystone crumb, a Vesta crumb and a Spark moment draw the same way.
  • gone, when the owner's own route answers 404: the item was deleted or never existed.
  • unreachable, when the owner does not answer, answers another status, answers with something that is not JSON, or answers FastAPI's bare {"detail": "Not Found"}. That last one means the owner is running code without the one-item route, and an owner that did not say the item is gone must never be reported as saying so.

A linked crumb's photo goes through Spark's media proxy, which is the other half of the contract. The proxy fetches only a URL the owner's resolved card named, picked by index, so it can never become an open fetch for whatever URL a request supplies. It keeps nothing: the answer goes back with a short private cache header. Candidates to link come from each owner's own search, and an owner that does not answer is named in the reply instead of silently dropped. A malformed reference, or one that names no known owner, is refused before anything is written, and links survive a rebuild of the mirror like every other op.

Send to Spark

The link usually starts in the owner's app. Waystone and Vesta each carry a Send to Spark action that opens Spark with the reference in the address. On the web that is a query, /?link_app=&link_kind=&link_id=&link_parent=&link_label=. On the phone it is a custom URL scheme, cwkspark://link?app=&kind=&id=&parent=&label=, read by the third code block. Either way only the reference travels. The next moment Dad captures arrives carrying the link, and the pending link clears only once a moment really carries it, so a capture that fails keeps it for the next try.

Principle: Link to what another app owns by a reference it can answer for, and ask it every time. A copy is right on the day you make it and quietly wrong after; a reference is either right or honestly unreachable.

Code

cwkSpark, backend/mirror.py: the trip a day belongs to, and a trip's moments by assignment or by date (verbatim, lines 553-587)·python
    def trip_on(self, local_date: str) -> str | None:
        """The trip whose dates hold this day, when exactly one does."""
        found = [
            trip["id"]
            for trip in self.trips()
            if trip.get("starts_on") and trip.get("ends_on") and trip["starts_on"] <= local_date <= trip["ends_on"]
        ]
        return found[0] if len(found) == 1 else None

    def trip_moments(self, trip: dict[str, Any]) -> list[dict[str, Any]]:
        """A trip's moments with detail, oldest first: assigned to it, or
        unassigned and within its dates."""
        clauses = ["trip_id = ?"]
        params: list[Any] = [trip["id"]]
        if trip.get("starts_on") and trip.get("ends_on"):
            clauses.append("(trip_id IS NULL AND local_date BETWEEN ? AND ?)")
            params += [trip["starts_on"], trip["ends_on"]]
        ids = [
            row[0]
            for row in self.db.execute(
                f"SELECT id FROM moments WHERE deleted = 0 AND ({' OR '.join(clauses)}) ORDER BY captured_at, seq",
                params,
            )
        ]
        out = []
        for moment_id in ids:
            moment = self.moment(moment_id)
            if moment is None:
                continue
            moment["results"] = [
                json.loads(row[0])
                for row in self.db.execute("SELECT record FROM results WHERE moment_id = ? ORDER BY seq", (moment_id,))
            ]
            out.append(moment)
        return out
cwkSpark, backend/kit_item_ref.py, vendored from cwkSiblingKit: found, gone or unreachable, asked of the owner (verbatim, lines 210-242)·python
async def resolve(ref: ItemRef, *, transport: httpx.AsyncBaseTransport | None = None) -> dict[str, Any]:
    """Ask the owner for the item: ``{ref, status, card?, error?}`` where
    status is ``found``, ``gone`` or ``unreachable``. Never raises for the
    owner's answer; the caller shows each status as it is."""
    owner = OWNERS[(ref.app, ref.kind)]
    base = {"ref": ref.as_dict()}
    try:
        async with httpx.AsyncClient(timeout=TIMEOUT_SECONDS, transport=transport) as client:
            response = await client.get(item_url(ref))
    except httpx.HTTPError as exc:
        return {**base, "status": "unreachable", "error": f"{ref.app} did not answer ({type(exc).__name__})"}
    if response.status_code == 404:
        if _route_missing(response):
            # An owner still on code without the one-item route: it did not
            # say the item is gone, so neither may the linker.
            return {**base, "status": "unreachable",
                    "error": f"{ref.app} does not answer for one {ref.kind} yet (it needs a restart on current code)"}
        return {**base, "status": "gone"}
    if response.status_code != 200:
        return {**base, "status": "unreachable", "error": f"{ref.app} answered HTTP {response.status_code}"}
    try:
        answer = response.json()
    except ValueError:
        # A web client's page, say, from an owner whose static mount caught
        # the path: whatever it is, it is not the item.
        return {**base, "status": "unreachable", "error": f"{ref.app} did not answer with the item"}
    if not isinstance(answer, dict):
        return {**base, "status": "unreachable", "error": f"{ref.app} did not answer with the item"}
    card = owner.card(answer)
    origin = owner.origin()
    card["media"] = [{**item, "url": origin + item["url"] if item["url"].startswith("/") else item["url"]}
                     for item in card["media"]]
    return {**base, "status": "found", "card": card}
cwkSpark, clients/apple/SparkMobile/Sources/AppModel.swift: Send to Spark on the phone reads only a reference (verbatim, lines 357-368)·swift
    /// Reads a `cwkspark://link?app=&kind=&id=&parent=&label=` address; any
    /// other address is not a link and is ignored.
    nonisolated static func pendingLink(from url: URL) -> PendingLink? {
        guard url.scheme == "cwkspark", url.host() == "link",
              let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems else { return nil }
        func value(_ name: String) -> String? {
            items.first { $0.name == name }?.value?.trimmingCharacters(in: .whitespacesAndNewlines).nonEmpty
        }
        guard let app = value("app"), let kind = value("kind"), let id = value("id") else { return nil }
        return PendingLink(ref: ItemRef(app: app, kind: kind, id: id, parent: value("parent")),
                           label: String((value("label") ?? "").prefix(200)))
    }

External links

Exercise

In an app of your own that sits beside another service, link records to the other service's items by reference instead of copying them. Store only the owner's name, the item's kind and id, a parent when the id needs one, and the label the user saw. Write a resolver that returns found with a card, gone on the owner's own 404, and unreachable for silence, errors, non-JSON and a missing route. Add a deep link that opens your app with a reference in it and attaches it to the next record the user creates, clearing it only after that record exists. Separately, group records into date ranges: assign at creation when exactly one range fits, and list unassigned records inside the dates on the range's page.
Hint
Tell a missing route apart from a missing item by the body your framework returns for an unmatched path. Test the overlap case directly: two ranges sharing a day should leave a record on that day unassigned but visible on both pages.

Progress

Progress is local-only — sign in to sync across devices.
Spotted a bug or have feedback on this page?Report an Issue

Comments 0

🔔 Reply notifications (sign in)
Sign in — Please sign in to comment.

No comments yet — be the first.