Task · SFL-0035

cli: doctor's pubsub probe discards TopicInfo.can_subscribe - a topic the integration user cannot subscribe to reports PASS

Status
To Do
Labels
followup, phase-1, roadmap
Milestone
Feature roadmap — operator ergonomics & platform capabilities
Updated
2026-08-14

Description

What

sf2loki doctor’s per-topic Pub/Sub check treats “the GetTopic RPC did not raise” as proof the topic is usable, and throws away the one field in the response that states whether subscription is permitted.

GetTopic returning OK proves the channel exists and the caller is authenticated for it; can_subscribe=false is the API’s distinct answer for “exists, but this principal is not authorised to subscribe” (missing Read on the platform event’s entity, a permission-set grant that covers the channel but not the subscribe right, a publish-only entitlement). Doctor currently cannot distinguish that from a fully working topic.

Why it matters

Deployment sequence today when the integration user lacks subscribe rights on a configured channel:

  1. sf2loki doctor prints pubsub:/event/MyCustomEvent PASS topic reachable and exits 0 (src/sf2loki/doctor.py:818-826 derives exit 1 only from a FAIL row).
  2. The operator deploys.
  3. PubSubSource._stream_topic (src/sf2loki/sources/pubsub_source.py:461-640) fails the Subscribe stream and enters its unbounded exponential-backoff reconnect loop — stream_up.set(0) at lines 573-581 / 630-636, backoff capped at max_backoff, retried forever.
  4. The problem surfaces only as sf2loki_pubsub_stream_up=0 and repeated reconnect logs, i.e. via dashboards/alerts minutes-to-hours later, and reads as a connectivity fault rather than a permission gap.

This is exactly the class of first-run misconfiguration doctor was built to front-load (src/sf2loki/doctor.py:1-12, issue #22). A wrong PASS is worse than an absent check: it directs the operator away from the real cause. The fix costs one field read on a code path that only runs in a one-shot CLI.

Proposed approach

  1. Change the get_topic contract to surface the response instead of dropping it. Either return the raw pb.TopicInfo, or — preferred, to keep protobuf types out of doctor.py and keep mypy --strict clean without type: ignore at the call site — return a small frozen dataclass in src/sf2loki/salesforce/pubsub_client.py:

    @dataclass(frozen=True, slots=True)
    class TopicProbe:
        topic_name: str
        can_subscribe: bool
        tenant_guid: str
        schema_id: str

    async def get_topic(self, topic: str) -> TopicProbe, built from the GetTopic response. Error handling stays exactly as it is (self._handle_rpc_error(exc) then re-raise, so the UNAUTHENTICATED token-invalidation behaviour pinned by tests/salesforce/test_pubsub_client.py:912-928 is unchanged). Update the docstring, which currently documents the discard.

  2. In _check_pubsub (src/sf2loki/doctor.py:276-282), inspect the result:

    • can_subscribe true -> PASS, "topic reachable" (unchanged text, so README’s sample output at README.md:308-320 stays valid).
    • can_subscribe false -> FAIL with an actionable detail naming the remedy, e.g. "topic exists but can_subscribe=false - grant the integration user Read on the platform event / check the channel's subscribe permission in the connected app's permission set".
    • Leave can_publish unused: sf2loki never publishes.
  3. Optionally include tenant_guid in the PASS detail only when it disagrees with the org id resolved by the auth check (src/sf2loki/doctor.py:149-162), as a wrong-org guard. Keep this out of scope if it complicates the row text — the can_subscribe gate is the substance.

  4. Update the two test doubles that implement the old signature: _FakePubSubClient.get_topic in tests/test_doctor.py:105-118 (currently -> None, raising RuntimeError for a topic containing "bad"), and the assertion in tests/salesforce/test_pubsub_client.py:888-910.

  5. Document the new FAIL row in docs/troubleshooting.md alongside the existing doctor rows, and in the doctor section of docs/reference/cli.md, with the permission remedy.

No config surface changes, no generated-artifact regeneration (just gen-config not required), no proto change (can_subscribe is already in the generated stub).


Imported from GitHub issue #119 on 2026-08-14, when this repo migrated from GitHub Issues to Backlog.md. The original issue has been deleted; its verbatim body, labels and comments are preserved in archive/issues-dump.json (jq '.[] | select(.number == 119)' archive/issues-dump.json).

Filed from the 2026-07-30 full-repo audit (11 finder lanes + adversarial verification per finding).

Acceptance Criteria

Definition of Done

References

View the source file on GitHub