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.