Skip to content

[FEAT] Messages ownership, write-once model, and state in data.value - #243

Open
glesage wants to merge 31 commits into
devfrom
feat/messages-upgrade
Open

[FEAT] Messages ownership, write-once model, and state in data.value#243
glesage wants to merge 31 commits into
devfrom
feat/messages-upgrade

Conversation

@glesage

@glesage glesage commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

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

  • Write-once messages: Messages are immutable after creation; cancelling a transfer is itself a message
  • State in data.value: Each message carries its own state (pending, accepted, rejected, cancelled) in data.value
  • Ownership-scoped sync: Every resource declares its ownership scope; sync requests are scoped accordingly
  • Private records: Messages and devices are private — only the owner can read them
  • Accept/reject/cancel: Recipients can accept or reject transfers; senders can cancel

iOS client

  • Ownership model: New EVY+Ownership.swift handles ownership-scoped reading and writing on the client side
  • Interpreter updates: interpreter.swift heavily refactored with message-state-aware interpretation (+252/-69)
  • Swipeable rows: Simplified swipe gesture handling in EVYSwipeableRow.swift (-76 lines)
  • Search improvements: Search model updated for ownership-scoped results
  • E2E test expansion: ios/e2e/e2e.swift grew significantly with ownership and message flow tests
  • Unit tests: Extensive new tests for interpreter, action runner, and store routing

Schema & types

  • Data schema, file schema, and core resources schema updated for ownership and visibility
  • Grammar conformance docs expanded
  • Validators updated for the new message state model

Docs

  • Updated docs/evy/data.md, docs/evy/methods.md, docs/evy/actions.md to document latest-message model, response messages, and the hard-coded client rule

Scripts & fixtures

  • New scripts/generate-core-resources.ts for generating core resource definitions
  • Fixtures updated to reflect new message state and ownership patterns
  • New test: scripts/shipped-fixture-action-branches.test.ts

Tests ran

  • API unit tests (data, sync, services, conflicts)
  • iOS unit tests (interpreter, action runner, search model, store routing)
  • iOS E2E tests
  • API E2E tests
  • Web unit tests and integration tests
  • Service marketplace tests

Risks & suggestions

  • This is a significant architectural change to the messages and sync system — ensure downstream consumers (especially the marketplace service) are aligned with the new ownership model
  • The iOS client has the bulk of the changes; thorough iOS E2E pass recommended before prod deployment
  • Database migrations are additive only (no destructive changes to existing data)

glesage and others added 30 commits July 28, 2026 22:22
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 glesage added the Mobile iOS app changes label Jul 29, 2026
@glesage
glesage marked this pull request as ready for review July 29, 2026 16:00
@glesage
glesage enabled auto-merge (squash) July 29, 2026 16:00
@glesage
glesage disabled auto-merge July 29, 2026 16:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Mobile iOS app changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant