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:
- 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.
- 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.
Context
LinkResolutionGate.resolvefilters candidates down to Links the Plug already holds a grant on, and reports everything else asMissingLink: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:
Both arrive as
MissingLink(requirementName, transport, direction), which carries noLinkIdbecause in case 1 there is none.Why this matters downstream
Socket hit this in socket-link/socket#1194.
NATIVE_FRAMEWORKLinks 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
LinkRequirementitself, queryLinkStore.getdirectly to check the Link is really there, show the sheet, record the grant, then re-resolve. Every one of those steps re-derives somethingLinkResolutionGatealready knew and discarded.Proposal
Add a distinct
LinkResolutionFailurevariant for the ungranted case, carrying theLinkIdthe caller would need to request a grant on — something like:Returned when
matchingTransportis non-empty butvisibleis empty.MissingLinkthen 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
LinkResolutionFailureneed a new branch.LinkResolutionGateis side-effect-free and DB-free by design, so this is testable without touchingLinkResolutionService.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.