proposals/2026-08-31-the-requester-closes-the-ask.md
Rama master · réplica de lectura del repositorio original.
# The requester closes the ask
**Goal** - an answered music request stops being pending on every OTHER library it was
filed with, without hosts ever talking to each other. Ported from PearCinema's proposal
2026-08-22 (the shape was Tim's call there: the requesting device coordinates), adapted
to PearTune's request plumbing.
**Tier** - T3. It moves an auth gate: `owner.requestResolve` is owner-only today, and
this lets the person who FILED a row resolve their own copy of it - a change to who may
write what on somebody else's host. Everything else about it is small.
## What is wrong
A request is filed with EVERY reachable host on purpose - none of them has the music, so
any of their owners might add it (`src/bare.js` `requestAdd` fans out in merged mode,
and `collapseRequests` folds the per-host rows back into one ask).
Resolving is where the symmetry stops. `owner.requestResolve` writes the row on the host
it was called on and notifies exactly one party: the requester
(`resolveRequestAndNotify` -> `request:resolved`). The sibling hosts holding their own
copy are never told, so their owners keep seeing a pending ask that is already answered,
and a second owner can add music somebody already added.
It is only closed everywhere when a PHONE owner does it: the worklet's
`ownerResolveRequest` fans out across `merge.resolveTargets`. But that fan-out reaches
only libraries the RESOLVER owns - and two different owners on two machines is exactly
the case the app is built for, so a dashboard resolve (or a resolve by an owner who owns
only their own box) leaves every sibling copy pending forever.
## The shape
The requesting device coordinates, because it is the only party that knows every copy of
the ask exists - it already holds them as `refs` (each `{ libraryId, id, status }`),
which is what lets REMOVE delete an ask everywhere today.
1. A host resolves. It pushes `request:resolved` to the requester exactly as now - no
protocol change, no new event.
2. The requester's device notices one copy of its ask says `added` while others still
say `pending`, and closes the pending copies as `added`, fire and forget.
3. It HEALS rather than fires once: the same check runs whenever the requester lists
their own requests, so a device asleep when the answer came settles it on next open.
The `request:resolved` push already makes the open view re-list, so the immediate
case is covered by the reload that happens anyway.
### Why the auth gate has to move
Step 2 is a write to a host where the requester may be a guest, and
`owner.requestResolve` refuses anyone whose grant is not owner-scope. The rule becomes:
the library's owner may resolve any row, and **the person who filed a row may resolve
that row**. The requester is identified the way it always is - `ownerOf(grant)` from the
Noise-authenticated connection compared to `row.requester` - the exact test
`request.delete` already uses to let a requester make their ask VANISH from the owner's
queue. Marking it answered is strictly less power than deleting it.
### Only `added` travels
A decline is one owner's answer about their own library, not everybody's: a `declined`
on host A leaves host B pending, so another owner may still add it.
`merge.resolveTargets` already refuses to rewrite a non-pending copy; this is the same
rule from the other end. Said out loud: declining from a dashboard still happens per
library; a phone owner who owns all the libraries fans out both verdicts, unchanged.
## Scope
- `host/media.js` `owner.requestResolve`: fetch the row first; admit owner scope OR
`row.requester === ownerOf(grant)`.
- `worklet/merge.js`: a pure `answeredElsewhere(collapsedRows)` - the pending refs of
rows that are `added` somewhere - so the decision is unit-testable without a host.
- `src/bare.js` `requestList` (merged branch): fire-and-forget close of what
`answeredElsewhere` names, then collapse and answer as before. Errors swallowed.
- NOT the store, NOT a new field, NOT host-to-host (host A never learns host B exists),
NOT the dashboard's own queue (it keeps showing that host's rows).
## Compat
An old host refuses the requester's close with `owner only`; the phone swallows it and
that host stays pending - a mixed fleet degrades to today's bug, not to an error. A new
host with an old phone is unchanged: nothing calls the relaxed path. Nothing stored
changes shape.
## Verify
Unit: `answeredElsewhere` truth table (added+pending closes the pending refs; declined
does not travel; all-pending closes nothing; a row with no refs closes nothing). Host:
the requester may resolve their own row and only their own (a stranger's id is refused);
owner behaviour unchanged. Integration over the DHT testnet: two hosts, one ask filed
with both, owner of host A resolves `added` on A only; the requester's next `requestList`
closes B's copy, and B's owner queue shows it resolved - while a `declined` on A leaves
B pending. Emulator against two local hosts if practical, else the testnet stands.
## Rollback
Revert the host and the gate is owner-only again; the phone's close attempts are
refused and swallowed - today's behaviour. Nothing to migrate.