Skip to content

LinkResolutionGate can't distinguish "no such Link" from "Link exists but this Plug has no grant" #684

Description

@wow-miley

Context

LinkResolutionGate.resolve filters candidates down to Links the Plug already holds a grant on, and reports everything else as MissingLink:

// A Link the Plug was never granted is not a candidate at all — it is
// invisible, not rejected. Revoked grants stay visible so the failure
// can say "revoked" rather than "missing".
val visible = matchingTransport.filter { link -> grants.grantFor(link.id) != null }

if (visible.isEmpty()) {
    return if (requirement.optional) LinkResolution.Skipped(requirement)
    else LinkResolution.Failed(requirement, LinkResolutionFailure.MissingLink(...))
}

LinkResolutionGate.kt:37-59, ampere-core 0.13.0.

The invisibility rule itself is right — an ungranted Link should not silently satisfy a requirement. The problem is that the failure it reports collapses two states a caller needs to tell apart:

  1. No Link of this transport exists at all. A misconfiguration, or a Plug on a platform that has no such wire. Nothing the user can fix.
  2. A Link exists and is perfectly serviceable, but this Plug has never been granted it. A consent question with an obvious remedy: ask.

Both arrive as MissingLink(requirementName, transport, direction), which carries no LinkId because in case 1 there is none.

Why this matters downstream

Socket hit this in socket-link/socket#1194. NATIVE_FRAMEWORK Links are seeded at first launch (they model "this device has EventKit" — no credential, no handshake), and consent is recorded separately as a grant when the user approves an allow sheet. That is the shape ampere's own Link/grant split implies.

But a Plug's first use is always case 2, and Socket cannot detect it from the resolution failure. To drive just-in-time consent it has to reach around the resolver: recompute the Link id from the LinkRequirement itself, query LinkStore.get directly to check the Link is really there, show the sheet, record the grant, then re-resolve. Every one of those steps re-derives something LinkResolutionGate already knew and discarded.

Proposal

Add a distinct LinkResolutionFailure variant for the ungranted case, carrying the LinkId the caller would need to request a grant on — something like:

data class UngrantedLink(
    val requirementName: String,
    val linkId: LinkId,
) : LinkResolutionFailure

Returned when matchingTransport is non-empty but visible is empty. MissingLink then means what its name says: nothing of this transport exists.

Worth deciding as part of this: when several ungranted candidates match, report the first in list order (matching the existing "first candidate's failure wins" determinism rule at LinkResolutionGate.kt:67-70) or report all of them.

Scope notes

  • Pure addition to a sealed hierarchy, no behaviour change to the resolve/reject decision — only to how the rejection is described. Callers exhaustively matching on LinkResolutionFailure need a new branch.
  • LinkResolutionGate is side-effect-free and DB-free by design, so this is testable without touching LinkResolutionService.

Blocking status

Not blocking Socket. The Socket-side consent wiring can ship with the workaround described above. This removes the workaround rather than unblocking it — file it as cleanup, not a dependency.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    Status
    Backlog

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions