36 KiB
issue, issue_title
| issue | issue_title |
|---|---|
| 726 | pi-permission-system: permission decisions record no responder provenance — a human approval is indistinguishable from an auto-approval |
Decision provenance — decidedBy on every permission decision
Release Recommendation
Release: ship independently
Phase 13 Step 6 carries Release: independent in the roadmap, and the phase's two release batches ("presentation-payload", "presentation-contract") are both closed — Steps 1–4 have landed.
This change is additive to the review log and version-skew tolerant on the forwarding wire, so it ships as a feat: on its own.
Problem Statement
The review log records that a permission request was resolved, but not what resolved it.
Every terminal event states an outcome (approved, blocked, session_approved) and a resolution, and the decider is left to be inferred from the event name — which works for the fast paths and fails completely on the ask path, where a human at a TUI dialog, a registered Authorizer link, an absent-authority denial, and a relayed answer from another session all collapse into the same {approved, state, denialReason}.
The reported case is exactly that failure.
A pre-completion-reviewer subagent ran find /, the external_directory gate fired correctly, the ask was forwarded, forwarded_permission.prompted was logged, and 21.5 s later forwarded_permission.approved appeared.
Three explanations — the operator clicked approve, the prompt rendered somewhere unseen and something else resolved it, an auto-approve path resolved it after the prompt opened — leave byte-identical traces.
For a gate whose entire purpose is the distinction between the user approved a filesystem-wide read and the system approved it on the user's behalf, "a decision was made" without "by whom" is not an audit record.
Measured on the operator's live 7.44 MB review log (9522 lines): 1432 terminal prompted decisions (1160 permission_request.approved + 272 .denied) carry no decider, against 8 authorizer_chain_resolved and 15 model_judge.decision entries — so a chain link could have decided some of them, and nothing in the log says whether it did.
The information is not missing; it is discarded. Each resolution site knows what decided at the moment it decides, and the fact dies before the log write.
Goals
- Add a
DecisionSourcediscriminated union (decidedBy) threaded from each decision site, never inferred from an event name or aresolutionvalue. - Record it on every terminal review-log entry: the ask path, the session/yolo/infrastructure fast paths, the policy-deny path, the gate-error boundary, and both sides of the forwarding exchange.
- Carry it on
ForwardedPermissionResponseso a subagent's decision is traceable end to end: the child's own terminal entry names the session that answered and what within that session decided. - Make the field structurally unforgettable — required on
PermissionPromptDecisionandGateBypassonce the threading is complete, sotscproves every decision names its decider. - Non-breaking: additive to the review log; the wire field is optional and tolerantly read, so a version-skewed child or parent degrades rather than failing.
Non-Goals
permissions:decisionbus event. Operator decision at planning time: hold off until the channel's consumers are known.PermissionDecisionEventis untouched by this plan, and the roadmap's Step 6Outcome:is corrected to match the narrowed scope./permissionshistory view (issue's third suggestion). No such view exists —/permission-system(src/config-modal.ts) is a config modal. Nothing to surface into.- The cross-ID-space join (the issue's secondary observation).
Resolved by #752:
ParentAuthorizernow adopts the requester'srequestIdas the forwarded request'sid, sopermission_request.*andforwarded_permission.*share one id. - Responder agent name on
forwarded_permission.*(the rest of the secondary observation). The serving node's human is the decider on the prompted path; an agent name there names the wrong actor. Not planned. - Collapsing the flat review-log columns (
surface,matchedPattern,sessionApprovalPattern) intodecidedBy— see Open Questions. - #753 (the gate-error boundary emits no
permissions:decision) — filed separately during Step 9, still open, and touching the same boundary. This plan writesdecidedByonto that boundary's review entry but does not add the missing emit. - #610 Step 10 (cross-session prompt/decision correlation), which also enriches the review-log write path. The roadmap says land Steps 6 and 10 in sequence, not concurrently.
Background
Where decisions are made
| Site | Terminal review event | What decides | Recorded today |
|---|---|---|---|
gates/bash-external-directory.ts bypass |
permission_request.session_approved |
session rules | no |
gates/bash-path.ts bypass |
permission_request.session_approved |
session rules | no |
gates/external-directory.ts bypass |
permission_request.infrastructure_auto_allowed |
infra-read containment | by event name |
gates/runner.ts session fast path |
permission_request.session_approved |
session rule + pattern | sessionApprovalPattern only |
gates/runner.ts yolo fast path |
permission_request.auto_approved |
yoloMode / origin: "yolo" |
by event name |
permission-gate.ts deny arm |
permission_request.blocked (policy_denied) |
config rule | matchedPattern only |
permission-gate.ts allow arm |
(no entry) | config rule | n/a |
authority/permission-prompter.ts |
permission_request.approved / .denied |
whoever the chain returned | no |
handlers/tool-call-boundary.ts |
permission_request.blocked (gate_error) |
a thrown gate | by resolution |
authority/forwarded-request-server.ts |
forwarded_permission.auto_approved / .auto_denied |
serving node's rules | by event name |
authority/forwarded-request-server.ts |
forwarded_permission.approved / .denied |
serving node's chain | no |
authority/approval-escalator.ts |
forwarded_permission.response_received |
the parent — opaque | responderSessionId only |
A plain policy allow writes no review entry at all, so it is out of the log's scope by construction; its provenance would only ever have surfaced on the bus, which this plan excludes.
Why the ask path loses it
composeAuthorizerChain (src/authority/authorizer-chain.ts) maps an AuthorizerVerdict to a PermissionPromptDecision through decideFromVerdict, which produces a bare {approved, state}.
The link's name is available one layer up — AuthorizerSelection.resolveConfiguredLinks logs it as authorizer_chain_resolved — and is then dropped when each link is wrapped as an anonymous { authorize } object.
So the chain structurally cannot say which link decided, only which links were consulted.
Downstream, LocalUserAuthorizer calls requestPermissionDecision (src/authority/permission-prompt-component.ts), the single place the mode === "tui" inline-dialog vs select/input-fallback dispatch is made.
That dispatcher is where the human's surface is known — the pure reducer below it (permission-prompt-decision.ts) must not learn about provenance.
Constraints that already hold
writeLinebounds and redacts nested values.capLogFieldWidths(src/log-field-cap.ts) recurses through plain objects and arrays, andredactedJsonStringify(src/log-redaction.ts) masks by key name through a JSON replacer. A nesteddecidedByis therefore width-capped and key-name redacted with no new work — and no new write path may bypasswriteLine.- Fail-closed at the wire boundary.
decidedByarrives off disk in a response file, so its reader is a tolerantasX-style guard beside its type (theasPromptPayloadprecedent), and it must be depth-bounded: a recursive guard over untrusted JSON is a stack-overflow surface. #752 set this precedent when it validated an adopted request id before letting it name a file. - Additive wire fields are version-skew tolerant (ADR 0011 §9): an older parent sends no
decidedBy, and the child recordsdecision: nullinside the forwarded variant rather than rejecting the response. - Required beats conventional.
Step 1's landed note records the lesson:
PromptPermissionDetails.payloadis required, "making 'every ask carries a complete payload' a compile-time guarantee rather than a convention." The same applies here, reached by lift-and-shift.
Design Overview
The type
New module src/authority/decision-source.ts, beside the decision types it annotates:
/**
* What decided a permission request, recorded at the decision site.
*
* Never inferred from an event name or a `resolution` value: each site
* constructs its own variant, so a new resolution path cannot silently
* inherit another's provenance.
*/
export type DecisionSource =
| { kind: "user"; via: "dialog" | "select" }
| { kind: "authorizer"; name: string; verdict: "allow" | "deny"; reason: string | null }
| { kind: "rule"; surface: string; pattern: string | null; origin: string | null }
| { kind: "session_approval"; surface: string; pattern: string | null }
| { kind: "yolo"; pattern: string | null }
| { kind: "infrastructure_read" }
| { kind: "unavailable"; reason: string }
| { kind: "gate_error"; reason: string }
| {
kind: "forwarded";
responderSessionId: string | null;
/** The serving node's own source; `null` when it sent none (version skew). */
decision: DecisionSource | null;
};
The union is self-contained: each variant repeats the detail that made it decisive.
On a local review line that duplicates surface and the pattern column; across the forwarding boundary it is the only shape that survives, because ForwardedPermissionResponse carries no surface, pattern, or origin column to lean on.
origin and the link name are new facts appearing on a review line for the first time.
The forwarded variant is recursive through an object property, which TypeScript resolves lazily; an exhaustive switch (source.kind) still type-checks as total.
Measured growth on the operator's 7.44 MB log: 5777 decision-bearing lines at 765 bytes average, +95 bytes for a rule variant and +134 for a nested forwarded one — a 7.4% worst case, against the 28.7% #746 removed.
The seam
decidedBy rides the object each site already produces, so no site gains a second thing to remember:
PermissionPromptDecision(src/authority/permission-dialog.ts) gainsdecidedBy— every producer of a decision states who produced it.GateBypass(src/handlers/gates/descriptor.ts) gainsdecidedBy— the gate that short-circuits is the decider, and the runner stamps it onto the bypass's log entry (and its decision facts, for the one bypass that carries them).ForwardedPermissionResponse(src/authority/permission-forwarding.ts) gains an optionaldecidedBy.
It is not merged into GateRunner's shared logContext.
That context holds the facts every resolution of a gate shares; the decider is by definition not shared, so each write stamps its own.
Call-site sketches
The chain, once links are named:
// authorizer-chain.ts — links become { name, authorize }
for (const link of links) {
const verdict = await link.authorize(details, query, log);
if (verdict.kind === "defer") continue;
return decisionFromVerdict(link.name, verdict); // stamps { kind: "authorizer", name, … }
}
return terminal.authorize(details);
The relay, on the child:
// approval-escalator.ts — the parent's answer, nested under this session's view of it
const response = readForwardedPermissionResponse(this.logger, responsePath);
return response
? { ...response, decidedBy: { kind: "forwarded",
responderSessionId: response.responderSessionId,
decision: asDecisionSource(response.decidedBy) ?? null } }
: abandon("The parent session's permission response could not be read");
abandon already names which path gave up; it becomes { kind: "unavailable", reason } over the same string, so the denial reason and the provenance cannot drift.
The dispatcher, where the human's surface is decided:
// permission-prompt-component.ts — requestPermissionDecision
const via = view.mode === "tui" ? "dialog" : "select";
const decision = await (view.mode === "tui" ? presentInline(…) : presentFallback(…));
return { ...decision, decidedBy: { kind: "user", via } };
Stamping here rather than inside reducePrompt keeps the pure decision model free of provenance and puts via exactly where the surface choice is made — the decide-once rule.
Edge cases
- Yolo.
Two arms reach the yolo fast path: a composition-stage rewrite (
origin: "yolo"on the matched rule, #526) and anasksynthesized after resolution (#712). Both are{ kind: "yolo", pattern }, wherepatternis the preservedmatchedPattern— including a sentinel like<opaque-bash-wrapper>, which is what makes a yolo grant over a synthetic ask legible. - Grant-scope translation.
ForwardedRequestServer.applyGrantScoperewritesapproved_for_serving_sessionto a plainapproved; it must carrydecidedBythrough unchanged, or a whole-session grant loses its decider. - Serving-node policy arms.
resolveDecisioncurrently reads only.stateoffpolicy.resolve(...); it takes the wholePermissionCheckResultso therulevariant can carrymatchedPatternandorigin. - Escalation failure.
The
catcharm inresolveDecisionbecomes{ kind: "gate_error", reason }rather than an unattributed deny. - Version skew, both directions.
A newer child reading an older parent's response records
decision: null; an older child reading a newer parent's response ignores the unknown field (its reader is already an allowlist).
Module-Level Changes
New
src/authority/decision-source.ts— theDecisionSourceunion, the depth-bounded tolerant guardasDecisionSource, and small constructors where a site would otherwise repeat a literal.test/authority/decision-source.test.ts— guard behavior: each variant round-trips, a malformed variant yieldsundefined, nesting beyond the bound yieldsundefined, an unknownkindyieldsundefined.
Changed
| File | Change |
|---|---|
src/authority/permission-dialog.ts |
PermissionPromptDecision.decidedBy; createDeniedPermissionDecision takes the source; requestPermissionDecisionFromUi stamps {kind:"user",via:"select"} |
src/authority/permission-prompt-component.ts |
requestPermissionDecision stamps via at the mode dispatch |
src/authority/permission-prompt-decision.ts |
decision literals in reducePrompt — stamped by the dispatcher above, so this file changes only if the type requires a placeholder during the transition |
src/authority/authorizer.ts |
Authorizer links carry a name where the chain consumes them (a NamedAuthorizer wrapper; the public registerAuthorizer signature is unchanged) |
src/authority/authorizer-chain.ts |
composeAuthorizerChain takes named links; decideFromVerdict stamps {kind:"authorizer",name,verdict,reason} |
src/authority/authorizer-selection.ts |
resolveConfiguredLinks returns named links instead of anonymous ones |
src/authority/denying-authorizer.ts |
{kind:"unavailable",reason} |
src/authority/approval-escalator.ts |
abandon stamps unavailable; the response path nests the parent's source; forwarded_permission.response_received logs decidedBy |
src/authority/forwarded-request-server.ts |
policy arms stamp rule; catch stamps gate_error; applyGrantScope preserves it; recordForwardedDecision writes it to the log and the response file |
src/authority/permission-forwarding.ts |
ForwardedPermissionResponse.decidedBy?: DecisionSource |
src/authority/forwarding-io.ts |
readForwardedPermissionResponse admits the field through its tolerant read (an allowlist reader silently drops an unlisted field) |
src/authority/permission-prompter.ts |
writeReviewEntry writes decidedBy on the approved/denied entries |
src/permission-gate.ts |
the deny arm's permission_request.blocked write stamps rule — the source is passed in beside messages, derived from the resolved check |
src/handlers/gates/descriptor.ts |
GateBypass.decidedBy |
src/handlers/gates/runner.ts |
bypass branch merges gate.decidedBy into the log entry; session and yolo fast paths stamp their own; the rule source is built once from check and handed to applyPermissionGate |
src/handlers/gates/external-directory.ts |
bypass carries {kind:"infrastructure_read"} |
src/handlers/gates/bash-external-directory.ts |
bypass carries {kind:"session_approval",…} |
src/handlers/gates/bash-path.ts |
bypass carries {kind:"session_approval",…} |
src/handlers/tool-call-boundary.ts |
recordGateError stamps {kind:"gate_error",reason} |
Test touch points
decidedBy becomes a required field on PermissionPromptDecision, so per the AGENTS.md rule for a new required field on a shared interface, the grep is for constructors, not use sites.
Measured: ~150 decision object literals across 19 test files plus 5 helpers — test/permission-gate.test.ts, test/composition-root.test.ts, test/authority/{local-user-authorizer,permission-prompt-component,permission-dialog,authorizer-selection,permission-prompter,denying-authorizer,forwarded-request-server,approval-escalator,authorizer-chain,permission-prompt-decision}.test.ts, test/handlers/{shell-tool-alias,input,input-events,tool-call,tool-call-events}.test.ts, test/handlers/gates/runner.test.ts, and test/helpers/{handler,authorizer,external-directory,forwarding,gate}-fixtures.ts.
Many are toEqual assertions, which break as soon as production starts setting the field regardless of optionality — so the migration is per-producer, not deferrable.
The TDD order below is decomposed accordingly, and the required-ness flip is isolated to a final step (lift-and-shift; never a single step rewriting a large test file wholesale).
Doc updates
| File | Change |
|---|---|
docs/architecture/architecture.md |
mark Step 6 ✅ on the heading and the Mermaid node S6; add a Landed: note; correct the Step 6 Outcome: to drop "and decision event" (bus excluded by operator decision); flip the decidedBy health-metric row |
docs/architecture/architecture.md module tree |
new authority/decision-source.ts entry; amend the permission-prompter.ts, approval-escalator.ts, forwarded-request-server.ts, and permission-forwarding.ts entries to state current behavior (no issue-number provenance trail — only an active constraint earns a ref) |
docs/architecture/permission-prompter.md |
step 3 of the bracket names decidedBy alongside the state and denial reason |
docs/subagent-integration.md |
the correlatability paragraph gains the provenance half: the child's terminal entry now names the responder and what within it decided |
.pi/skills/package-pi-permission-system/SKILL.md |
Log-writes section: decidedBy is stamped at each decision site, nested and therefore already covered by the recursive width cap and key-name redaction; the wire field is tolerantly read and depth-bounded |
The health-metric recompute command is grep -rn "decidedBy" packages/pi-permission-system/src | wc -l (baseline 0, measured at planning time), and the roadmap's own note requires the creating step to use the roadmap's name — which this plan does.
Predicted post-change value: ~20–30 sites (one per decision site plus the type module and the threading points); the row's target is ≥ 1, so the prediction is not load-bearing, but the row must move off 0.
Test Impact Analysis
- New unit tests the change enables.
asDecisionSourceis a pure guard with a depth bound — directly unit-testable, asasPromptPayloadis. Each decision site becomes assertable in isolation:denying-authorizer.test.tscan pin{kind:"unavailable"}without a log fixture, andauthorizer-chain.test.tscan pin which link decided, which no test can express today. - Tests that become redundant.
None are removed.
The existing suites assert outcomes, and provenance is a new orthogonal fact — the assertions are extended, not replaced.
The one candidate for simplification is
forwarded-request-server.test.ts's indirect "which arm ran" checks (currently inferred from the logged event name), which can assertdecidedBydirectly. - Tests that must stay as-is.
permission-gate.test.ts's deny/ask/allow branching,runner.test.ts's fast-path ordering, andapproval-escalator.test.ts's abandonment paths all exercise control flow this change threads a field through without altering. Their existing assertions are the regression guard that threading changed nothing else.
Invariants at risk
| Invariant | Origin | Pinned by |
|---|---|---|
Every review-log line is width-capped and key-name redacted, with no bypass of writeLine |
Step 4 (#746), ADR 0010 | test/log-field-cap.test.ts + test/logging.test.ts — add a nested-decidedBy case: the cap and the mask must reach into the nested object |
| One request id runs from the child's gate to the serving node's decision | Step 9 (#752) | test/authority/approval-escalator.test.ts id-adoption tests — unchanged; this plan adds a field beside the id, never a second id |
| A forwarded request/response missing a newer field degrades rather than failing | Step 3 (#745), ADR 0011 §9 | test/authority/forwarded-request-server.test.ts version-skew tests — add a response with no decidedBy yielding decision: null |
| A relaying node runs no chain links (one chain per node) | ADR 0007 §7 (#727) | test/authority/authorizer-selection.test.ts — naming the links must not change linksFor's relaying arm, which still returns [] |
The bounded-delegation envelope caps an allow on an excluded surface to defer |
#635 | test/authority/delegation-envelope.test.ts — the envelope wraps authorize; naming links must wrap the named value so the cap still applies before the name is read |
| An adopted inbound value cannot steer this process | #752 | new decision-source.test.ts depth-bound case |
The quantitative invariant here is log size, measured above: +7.4% worst case on a 7.44 MB corpus.
That is a prediction from a real line census, not a prose argument, and the /tdd-plan session should re-measure against the finished shape before closing.
TDD Order
Each cycle is red → green → commit.
decidedBy is introduced optional and tightened to required in the final code cycle, so no single cycle has to rewrite a large test file wholesale.
- The type and its guard.
test/authority/decision-source.test.tscoversasDecisionSourcefor each variant, malformed input, unknownkind, and nesting past the depth bound. No production consumer yet.refactor(pi-permission-system): add the decision-source union and its tolerant guard (#726) - The human decider.
requestPermissionDecisionstamps{kind:"user",via}at the mode dispatch;requestPermissionDecisionFromUiandcreateDeniedPermissionDecisioncarry the source;PermissionPrompter.writeReviewEntrywritesdecidedBy. Tests:permission-prompt-component.test.ts(both modes),permission-dialog.test.ts,permission-prompter.test.ts.feat(pi-permission-system): record the human decider on prompted decisions (#726) - Named chain links.
composeAuthorizerChaintakes{name, authorize}links;decideFromVerdictstamps{kind:"authorizer",name,verdict,reason};AuthorizerSelection.resolveConfiguredLinkssupplies the names, still wrapping each in the delegation envelope. The export shape ofcomposeAuthorizerChainchanges, so its callers and their tests move in this same commit. Tests:authorizer-chain.test.ts,authorizer-selection.test.ts,delegation-envelope.test.ts.feat(pi-permission-system): name the authorizer link that decided an ask (#726) - The unavailable paths.
DenyingAuthorizerandParentAuthorizer.abandonstamp{kind:"unavailable",reason}over the string each already produces. Tests:denying-authorizer.test.ts,approval-escalator.test.ts.feat(pi-permission-system): attribute absent-authority denials (#726) - The non-prompting local paths.
GateBypass.decidedByon the three bypass sites; the runner's session and yolo fast paths;applyPermissionGate's deny arm;recordGateError. Tests:runner.test.ts,permission-gate.test.ts,tool-call-events.test.ts,external-directory-fixtures.tsconsumers.feat(pi-permission-system): record the decider on non-prompting resolutions (#726) - The forwarding wire.
ForwardedPermissionResponse.decidedBy?;forwarded-ioadmits it; the serving node stampsrule/gate_errorand preserves it throughapplyGrantScope; the child nests it as{kind:"forwarded",…}and logs it onresponse_received. Includes the version-skew case (decision: null). Tests:forwarded-request-server.test.ts,approval-escalator.test.ts,forwarding-io.test.ts, and the round-trip incomposition-root.test.ts.feat(pi-permission-system): carry decision provenance across the forwarding boundary (#726) - Tighten to required.
decidedBybecomes required onPermissionPromptDecisionandGateBypass; residual fixtures and mock returns are updated. Pure type flip — the behavior is already in place, so this is the compile-time guarantee, not new function.refactor(pi-permission-system): require a decider on every permission decision (#726) - Nested-value bound regression.
Add the nested-
decidedBycases to the width-cap and redaction suites, asserting a long nestedreasonis capped and a sensitive-keyed nested value is masked.test(pi-permission-system): pin the width cap and redaction over nested provenance (#726) - Docs.
Architecture module tree, Step 6
✅+ Mermaid node +Landed:note + correctedOutcome:+ health-metric row;permission-prompter.md;subagent-integration.md; the package skill.docs(pi-permission-system): record decision provenance and mark Phase 13 Step 6 complete (#726)
Risks and Mitigations
| Risk | Mitigation |
|---|---|
| A recursive tolerant guard over on-disk JSON is a stack-overflow surface | Depth bound in asDecisionSource, returning undefined past it; covered by a cycle-1 test. Forwarding is depth-1 by invariant, so the bound costs nothing real |
| ~150 decision literals across 24 test files make a one-shot migration a large-blast-radius commit | Per-producer decomposition (cycles 2–6), each touching the files for one decision site; the required-ness flip is isolated to cycle 7 |
| Naming chain links could disturb the delegation envelope or the relaying-node "no links" rule | Envelope wraps the named link's authorize, so the cap still runs before the name is read; linksFor's relaying arm still returns []. Both pinned by existing tests listed under Invariants |
| Self-contained variants grow the review log | Measured: +7.4% worst case on a real 7.44 MB corpus, against −28.7% just removed by #746. Every nested string is already width-capped by capLogFieldWidths |
| A nested object could escape the width cap or the key-name mask | It does not — both recurse — but cycle 8 pins it rather than trusting the reading |
An authorizer link's reason is model-generated text now persisted to the log |
It already is: permission_request.denied writes denialReason today. The bus, which ADR 0011 §6 calls the narrowest renderer, is excluded from this change entirely |
| Steps 6 and 10 both enrich the review-log write path | The roadmap says land them in sequence; this plan lands first and #610 rebases onto it |
Open Questions
- Should
permissions:decisioneventually carrydecidedBy? Deferred by operator decision at planning time: the channel's consumers are not yet known, and the bus is the narrowest renderer under ADR 0011 §6. Revisit when a consumer is identified; the shape would likely narrow to kind + decider identity, dropping free-textreason. Not filed as an issue — it is a watch item, not scheduled work. - Should the flat review-log columns collapse into
decidedBylater? The duplication issurface, the pattern column (matchedPattern/sessionApprovalPattern),reason/denialReason, andresponderSessionId/targetSessionId. If it becomes annoying, the removal is of the flat columns, notdecidedBy— the newer field is the one with no gaps. That is mechanical:renderReviewLogFacts(src/presentation/review-log-renderer.ts) is the single producer ofsurface/matchedPatternfor all review lines, andsessionApprovalPatternhas one write site inrunner.ts. It would ship as afeat!:with a migration note, the same class as #746 removingmessage. Not filed — speculative until the duplication proves costly. - Does a
{kind:"authorizer"}decision want the link's ownmodel_judge.decision-style detail inline? No: a link already records its own trail through the injectedAuthorizerLog, keyed byrequestId.decidedBynames which link decided; the link's record says why. Revisit only if a link's trail proves hard to join.