[FEAT] Messages ownership, write-once model, and state in data.value - #243
Open
glesage wants to merge 31 commits into
Open
[FEAT] Messages ownership, write-once model, and state in data.value#243glesage wants to merge 31 commits into
glesage wants to merge 31 commits into
Conversation
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A message reaches only its creator and its recipient, so it belongs in the private store rather than the public one. Adds the migration for the column default plus a backfill: existing public rows would otherwise belong to nobody, and bumping updated_at with them is what makes an entitled device re-receive the row and move it out of its public store. Migrations past 0000 are hand-written here - the drizzle snapshot is not maintained, so db:generate cannot diff. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Ownership is now the union of three sources: records this device created (the ledger), records it holds privately, and the launch override. The ledger is a set of (service, resource, id) triples in UserDefaults beside the sync cursor, replacing a whole SwiftData store used to hold empty bodies. It is what keeps a seller entitled to messages about a public item they listed - visibility cannot express that. The private store contributes too, so a message that arrives for you stays owned and its later updates keep coming. Local singletons share the private store but are not records: their id is not a uuid, and one bad id fails the entire sync request, so those namespaces are excluded and an assertion catches any that slip through. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
createWithGeneratedId defaulted every payload to visibility "public", which overrode every server-side per-resource default - so a message created on iOS was stored public despite messagesResource declaring private, and the same was already true of addresses. The client no longer sends a visibility it was not given. The local copy routes to the public store until the record syncs back with the value the server chose; ownership does not depend on that landing spot, since the ledger records the create either way. Also documents ownership's three sources and what visibility is not. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Each resource declares its visibility once in core.resources.json, and the generator emits it for both platforms. The creating client sends it and the API only validates that it arrived - makeCoreResource no longer injects a value, the file resource's three "?? public" fallbacks are gone, and the schema-level defaults that became Drizzle column defaults are removed, so the database cannot silently choose one either. Migrations are squashed to a single regenerated baseline. This also repairs bun run db:generate, which could not diff against the stale snapshot left by the hand-written migrations. Locally created addresses and messages now land in the private store, which is where their visibility says they belong; the tests that asserted the old placement now read from whichever synced store holds the record. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DATA_EVY_File requires a visibility and FileMetadata could not express one, so the two schemas described the same record and disagreed. With nothing defaulting it, an iOS photo upload would have been rejected outright - no test covered that path. FileMetadata and FileWithBinary now carry visibility like the record they describe, and the upload states it. Also seeds the e2e SDUI harness rows, pages and flows with their visibility, and updates the docs: nothing fills a visibility in any more. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The view-item page read its pickup location out of the private addresses collection, so making private mean owner-only would have blanked the map and location line on every item a device does not own - silently, since nothing errors and no test asserts a seeded item's address. transfer_options.pickup now carries postcode, latitude and longitude alongside address_id, copied from the address in the same action that writes the link so the two move together. The map and location line read the item; the unit and street stay on the private record. A fixture test fails if an action links an address without copying those fields - the failure mode is a page that renders less, which nothing else catches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every core resource can now be read as sync sees it: every public row, plus the private rows the calling device declared as owned. A resource with no visibility column has nothing to scope, so formatters keep syncing whole. Messages keep their recipient rule as a widening of the generic clause rather than a separate read path, so whoever owns the record a message addresses still receives it. listForSync is required on every registry entry: a resource without one would silently stop syncing. Also clears Formatter in clearAllTestTables, which it never did - formatter rows were leaking between tests the same way message rows were. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Messages rejoin the core loop and the special case disappears: every core resource is read through the same ownership-scoped path, handed the ids owned in that resource plus the records owned elsewhere that a message may address. Devices stay out because they are not served by the core read API at all, and the catalog because it is a singleton assembled separately - the comment now says so rather than implying messages were the reason. A test asserts the loop reads every resource the registry serves, since the failure mode is one resource quietly never syncing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A device row is nobody else's business. It stays out of sync either way - devices are not served by the core read API - but the declaration should say what the record is rather than leaving it public by omission. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Records what private now means: an access rule rather than a store choice, and one that removes rows silently. Notes the two limits - get is not an access boundary, and external service resources are public by construction - and why a public record that reads a private one has to carry what it needs itself. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A response addresses whatever record its request addressed, so the existing recipient rule already delivers it to that record's owner - who is the person who just answered. The request's sender owns neither the response nor that record, only the request, so nothing delivered the answer back to the asker. Add a third entitlement clause: a message whose data.message_id names a message you own is yours. Matched through data rather than fk/service/resource, because those are uuid columns while core resources are addressed by name, so a message can never address another message directly. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`status` was the mutable field the response model exists to stop writing, so rather than leave a column nothing reads, remove it. `data.value` carries the whole vocabulary: "pending" on a request, "accept" or "reject" on the response answering one. Requests say "pending" explicitly rather than leaving the key absent, so the predicates read as one state machine and a future message kind that carries no value is not mistaken for a transfer request. The def is `additionalProperties: false`, so a payload still carrying `status` is rejected rather than ignored, and iOS `update` echoes the whole record back. The migration's `updated_at` bump is what makes entitled devices re-receive these rows without the column instead of failing their next write on a stale one. An accepted request becomes a pending request plus an accept response, which is the one backfill statement that inserts. Also fixes the response rule shipped in the previous commit, which was a no-op against a real database. The bun-sql driver stores a jsonb column by JSON-stringifying it, so rows written by the API hold a jsonb *string* containing the object. Reads are symmetric so JavaScript cannot tell, but `data ->> 'key'` is NULL on that shape and `jsonb_set` refuses it outright. Reads now unwrap tolerantly, the migration normalises what it touches, and a test writes the production shape directly - pglite stores it properly, so nothing else would have caught this. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Every message predicate on the item page moves off `status`. The reads that asked "was this accepted?" now look for the response that says so, and drop `archivedAt == null` with it - a response is never archived, only the request it answers is. Two gates needed more than a substitution. The request container stays up once its request has been accepted, since that is where the confirmation row lives, so it tests an open request or an accepted one. The pickers ask the opposite: nothing unanswered in flight and nothing already accepted. Cancel deliberately gets neither term - it must disappear the moment a request is answered. The cancel confirmations used to filter on `fk + archivedAt + status`, and an update filter matches flat record keys so it cannot reach `data.value`. They name the request outright instead, which is both exact and narrower than the filter it replaces: it can only ever archive the one request the button was offered for. Accepting from the inbox creates the response and archives the request, in two actions on the same trigger. The gating stays wrong for now - it is offered to whoever holds the message - which is the next commit's job. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Which side of a request this device is on decides what it may do about it, and none of that is expressible in SDUI: `visible` is evaluated with no datum so a search child cannot vary per result, a row has one swipe affordance where a recipient needs two, and no expression can read ownership at all. So the rule lives in Swift, in one place, with the three gaps that would let it move back into a flow written down next to it. Authorship reads the ledger rather than `ownedServiceResources()`. Receiving a message also lands it in the private store, which confers ownership of it, so the wider set cannot tell "I wrote this" from "this reached me" - and getting that backwards would read a recipient as the sender and drop the affordance. `sender` wins when both hold, which is what stops the device that listed the item from accepting its own request. The tests that used `status` on the messages resource as a generic mutable field move to `archivedAt` and `data.value`. iOS never validated against the schema so they would have gone on passing against a field the contract no longer has. The interpreter cases are better for it: they now resolve through a nested record path, which is what the shipped predicates do. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A row's swipe reveal took one label and one handler, which is all SDUI can express - so it now takes a list, and the reveal scales with it. A recipient gets a blue check and a red cross; the sender of the same request gets only cancel and cannot answer their own ask. Nothing is offered once a request has been answered or archived, or to a device that is neither side of it. The inbox hides responses. A response is a message and lands in the same collection, so without filtering the list renders "pickup request" twice - once for the ask, once for the reply. It goes in `loadLocalResults`, the one place that knows the source key, because a `Search` child cannot filter per result: `visible` is evaluated with no datum. The hard-coded writes run under the row's own scope, the same reason `runActions` does it, so a mutation lands where the row's bindings read from. With the affordance in Swift, the shipped inbox fixture drops its `swipe-left` action entirely. Verified in the simulator both ways round: as the item's owner, accepting wrote a new message carrying `message_id`, `value: accept` and the copied type, and archived the request with its own `value` untouched; as the buyer, the same row offered cancel alone, which archived and created nothing. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Drives both sides through the UI: a recipient offered accept and reject, whose tap writes a new message and archives the request; a sender offered cancel alone, which archives and answers nothing. Plus the API round trip over the real transport, where the response reaches the asker through the response rule and nothing else. A response now carries the request's whole `data` forward rather than just its type. `findFirst` cannot nest, so a lookup that finds the response cannot reach through it to the request - and the item page reads the agreed time off the message that says "accepted". Without the payload, "Pickup confirmed for …" renders with nothing in it, which is what the reworked confirmation test caught. Each inbox case seeds its own request with a distinct time and filters on it. The device owns the item, so it receives every request addressed to it, and sharing one would make the result depend on alphabetical test order. Answering leaves a row with no actions, so it renders without the swipe wrapper and the `swipeRow_` element disappears entirely - a better assertion than swiping something that is no longer there. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`data.value` and the response model in data.md, alongside the third entitlement rule and why it cannot void the cursor. sdui.md records the swipe exception from the flow-authoring side, with the three gaps that keep it in Swift, so someone looking for the flow that puts accept on a message row learns there isn't one. Also warns about the jsonb double-encoding found while implementing this: the driver stores every jsonb column as a stringified object, so `data ->> 'key'` is NULL in SQL while JavaScript sees an object - and the pglite-backed unit tests store it properly, so they will not catch a clause that only works on the normalised shape. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The item page is about to ask "what is the latest message about this transfer method", which is findFirst(sort(messages, desc, createdAt), <predicate>). Nesting a collection call inside findFirst is already covered, but only in the one-argument form - the combination with a predicate is what the whole state machine will rest on, so pin it before anything depends on it. Fixture timestamps carry milliseconds deliberately. evySort breaks equal keys by original order regardless of direction, so second-resolution values would let the first-stored record win a desc sort, which is backwards for "latest wins". The fixture gains a createdAt parameter to make that explicit at the call site. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…archiving `createdAt` is about to become the only thing ordering a request against its answer, and `createWithGeneratedId` was writing seconds while the API, the seed fixtures and EVYAPIManager all wrote milliseconds. Two costs to that: mixed formats compare wrongly as strings, since `.` sorts before `Z`, and two writes in the same second tie - and a tie falls back to store order, which hands the answer to the request that was stored first. A test creates a request and answers it through the real path, microseconds apart, and reads back `pending` until this is fixed. With ordering reliable, archiving a request when it is answered stops doing any work: the response is newer, so it supersedes the ask by existing. `respond` no longer writes to the request at all. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The pattern that finds a function call in text tolerated one level of nested parentheses, so `formatDatetime(findFirst(sort(c, desc, f), pred).data.time, "EEE do")` did not match and rendered as its own source text - the item page showed "Delivery confirmed for formatDatetime(findFirst(sort(messages, desc, ..." instead of a date. Two levels worked, which is why the visibility expressions were fine and only the confirmation subtitle broke. Regex cannot balance parentheses to arbitrary depth, so this raises the ceiling rather than removing it, and says so where the pattern is defined. Going deeper means a paren-depth scan, which splitTopLevel already does for arguments. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The item page asked four questions per transfer type - is there an unanswered request, is there an accept, is this archived - and ANDed the answers. It now asks one: what is the latest live message for this item and this method, and what does its data.value say. `pending` shows cancel, `accept` shows the agreed time, and everything else - `reject`, or nothing yet - shows the timeslots. The picker's gate and the request container's gate are exact complements on the same lookup. Each method is now independent. The tab container was hidden whenever anything was live, which is what made the three mutually exclusive; it always shows, and each picker gates on its own method instead. So a buyer with a live pickup request can still ask for delivery, and rejecting one leaves the other alone. There is one lookup shape on the whole page now, and no "at most one arrangement is live" invariant to maintain. Verified in the simulator: pickup requested then cancelled, pickup and delivery live together each with its own cancel, pickup rejected while delivery stayed live, and delivery accepted showing "Delivery confirmed for Wed 3rd at 15:00" while the rejected pickup offered its timeslots again. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`archivedAt` is gone. A message's whole lifecycle is `data.value` over `createdAt`, so withdrawing a request is another message saying "cancel" rather than a mutation of the request - and nothing in the system updates a message any more. Accepting, rejecting and cancelling are now one operation with a different value, so they share a path. The migration turns an archived request with nothing answering it into a cancel message. The absence of an answer is what distinguishes withdrawal from acceptance, since answering used to archive the request too. Verified against planted rows in both jsonb shapes: the archived-unanswered one gained a cancel carrying its payload, the archived-but-answered one correctly gained nothing. `archivedAt` was the only key `assertIsoDateTimeJsonFields` allowed to be null, so that special case goes with it. Two test groups had been using messages as a vehicle for update mechanics, first via `status` and then via `archivedAt`. With no mutable field left they move to a scratch resource, which is where they belonged. The interpreter's null-comparison cases move to `data.message_id` - absent on a request, set on whatever answers it - which is a truer subject than `archivedAt` ever was. Verified in the simulator: cancelling wrote a cancel message naming the request, left the request untouched, and returned the timeslots. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Docs record the write-once lifecycle, the per-method latest-message read, and that `createdAt` is the ordering key with no fallback - which is why it carries milliseconds. methods.md gains the sorted-collection form of findFirst, with the two traps: ties fall back to store order, and a predicate matching nothing compares unequal to everything. The builder's collection placeholder now splits on top-level commas only, so a nested `sort(...)` shows the collection rather than "sort(messages". New e2e case for the rejection path: request, reject, timeslots return, and the buyer can ask again. That is the case the old predicates could not express. Two bugs the suite caught, both from answering no longer touching the request: - The inbox never refreshed after an answer. Archiving used to mutate the row the affordance was attached to, so its datum changed and the list re-rendered; now nothing about the request changes. The inbox lists open requests only, which is both what an inbox should show and what makes answering visible. - Every cancel sheet in the e2e flows shared one row id, so the last one seeded won and all three cancel buttons showed it. Harmless while the action was type-agnostic; once it names a method, cancelling pickup wrote a shipping cancellation. Each method gets its own sheet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The tab container holds all three request controls, so gating it on nothing being live means the item page shows one arrangement at a time: request pickup and the tabs give way to that request's own section - its address and its cancel button - rather than sitting above it. Settle it, by cancelling or being rejected, and the tabs come back. The gate is the exact complement of the three `Active … request` gates, term for term, so it needs no invariant to be correct: whenever one of those is up, this is down. Each method's state is still tracked on its own, which is what lets a rejected pickup leave delivery untouched - it is only the choosing that is one at a time. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Make Accept / Reject / Cancel fully declarative via filter() and owns(), carry the triggering row's datum into sheets, and drop EVYMessageRequest. Co-authored-by: Cursor <cursoragent@cursor.com>
glesage
marked this pull request as ready for review
July 29, 2026 16:00
glesage
enabled auto-merge (squash)
July 29, 2026 16:00
glesage
disabled auto-merge
July 29, 2026 16:05
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Overhaul the messages system with ownership-scoped sync, write-once semantics, and state tracked in
data.value. Transfer requests now flow end-to-end with accept/reject/cancel flows.Major changes
Messages & ownership
data.valueiOS client
EVY+Ownership.swifthandles ownership-scoped reading and writing on the client sideinterpreter.swiftheavily refactored with message-state-aware interpretation (+252/-69)EVYSwipeableRow.swift(-76 lines)ios/e2e/e2e.swiftgrew significantly with ownership and message flow testsSchema & types
Docs
docs/evy/data.md,docs/evy/methods.md,docs/evy/actions.mdto document latest-message model, response messages, and the hard-coded client ruleScripts & fixtures
scripts/generate-core-resources.tsfor generating core resource definitionsscripts/shipped-fixture-action-branches.test.tsTests ran
Risks & suggestions