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.
-
PubSubClient.get_topic(src/sf2loki/salesforce/pubsub_client.py:252-268) is declaredasync def get_topic(self, topic: str) -> None. It awaitsself._stub().GetTopic(pb.TopicRequest(topic_name=topic), metadata=await self._metadata())and never assigns or returns the response. TheTopicInfomessage is decoded by grpc and immediately discarded. -
_check_pubsub(src/sf2loki/doctor.py:276-282) calls it insidetry/except Exception, emittingCheckResult(f"pubsub:{topic}", "FAIL", _format_topic_error(exc))on any raise andCheckResult(f"pubsub:{topic}", "PASS", "topic reachable")otherwise. There is no other signal available to it. -
proto/pubsub_api.proto:17-32defines the response the RPC returns:message TopicInfo { string topic_name = 1; string tenant_guid = 2; bool can_publish = 3; // Is subscription allowed? bool can_subscribe = 4; string schema_id = 5; string rpc_id = 6; }rg can_subscribeacross the repo hits onlyproto/pubsub_api.proto:25, the generated stubsrc/sf2loki/salesforce/_generated/pubsub_api_pb2.py, andtests/salesforce/test_pubsub_client.py:894— where the fake servicer builds apb.TopicInfo(..., can_subscribe=True, ...)and the assertion (test_get_topic_returns_none_and_sends_metadata,tests/salesforce/test_pubsub_client.py:888-910) pins that the method returnsNone. No production code path reads the field.
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:
sf2loki doctorprintspubsub:/event/MyCustomEvent PASS topic reachableand exits 0 (src/sf2loki/doctor.py:818-826derives exit 1 only from a FAIL row).- The operator deploys.
PubSubSource._stream_topic(src/sf2loki/sources/pubsub_source.py:461-640) fails theSubscribestream and enters its unbounded exponential-backoff reconnect loop —stream_up.set(0)at lines 573-581 / 630-636, backoff capped atmax_backoff, retried forever.- The problem surfaces only as
sf2loki_pubsub_stream_up=0and 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
-
Change the
get_topiccontract to surface the response instead of dropping it. Either return the rawpb.TopicInfo, or — preferred, to keep protobuf types out ofdoctor.pyand keepmypy --strictclean withouttype: ignoreat the call site — return a small frozen dataclass insrc/sf2loki/salesforce/pubsub_client.py:@dataclass(frozen=True, slots=True) class TopicProbe: topic_name: str can_subscribe: bool tenant_guid: str schema_id: strasync def get_topic(self, topic: str) -> TopicProbe, built from theGetTopicresponse. Error handling stays exactly as it is (self._handle_rpc_error(exc)then re-raise, so the UNAUTHENTICATED token-invalidation behaviour pinned bytests/salesforce/test_pubsub_client.py:912-928is unchanged). Update the docstring, which currently documents the discard. -
In
_check_pubsub(src/sf2loki/doctor.py:276-282), inspect the result:can_subscribetrue ->PASS,"topic reachable"(unchanged text, so README’s sample output atREADME.md:308-320stays valid).can_subscribefalse ->FAILwith 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_publishunused: sf2loki never publishes.
-
Optionally include
tenant_guidin the PASS detail only when it disagrees with the org id resolved by theauthcheck (src/sf2loki/doctor.py:149-162), as a wrong-org guard. Keep this out of scope if it complicates the row text — thecan_subscribegate is the substance. -
Update the two test doubles that implement the old signature:
_FakePubSubClient.get_topicintests/test_doctor.py:105-118(currently-> None, raisingRuntimeErrorfor a topic containing"bad"), and the assertion intests/salesforce/test_pubsub_client.py:888-910. -
Document the new FAIL row in
docs/troubleshooting.mdalongside the existing doctor rows, and in the doctor section ofdocs/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
- #1
PubSubClient.get_topicreturns the topic’scan_subscribe(andtenant_guid/schema_id) rather thanNone; docstring updated to state that callers must checkcan_subscribe. - #2 Existing
GetTopicerror semantics unchanged: UNAUTHENTICATED still invalidates the cached token and re-raises; non-auth errors (e.g. NOT_FOUND) still propagate without invalidating. - #3
_check_pubsubemitsFAILfor a topic whoseGetTopicsucceeds withcan_subscribe=false, with a detail naming the permission remedy;PASS topic reachableretained whencan_subscribe=true. - #4 A doctor run containing such a topic exits 1 (
src/sf2loki/doctor.py:818-826) and the row appears in the--jsonpayload with"status": "FAIL". - #5 Test in
tests/salesforce/test_pubsub_client.py: fake servicer returnspb.TopicInfo(topic_name=..., can_subscribe=False, ...); assertget_topicreturns a result whosecan_subscribeisFalse(replacing the current returns-Noneassertion). - #6 Test in
tests/salesforce/test_pubsub_client.py:can_subscribe=Truecase returnsTrueand still sends the topic name plus auth metadata. - #7 Test in
tests/test_doctor.py: fakePubSubClientreturnscan_subscribe=Falsefor one configured topic; assert the resultingCheckResultis("pubsub:<topic>", "FAIL", <detail mentioning the permission remedy>)and that the overall exit code is 1. - #8 Test in
tests/test_doctor.py: happy path withcan_subscribe=Truestill yieldsPASS topic reachable(regression guard on the unchanged row text). - #9
docs/troubleshooting.mdanddocs/reference/cli.mddescribe the new FAIL row and its fix. - #10
just gategreen (ruff,mypy --strict, pytest) with no newtype: ignoreat the doctor call site.
Definition of Done
- #1 just gate is green (ruff check + ruff format –check + mypy src + pytest) — run it, don’t assert it
- #2 just gen-config run and its output committed, if config.py changed (CI drift gate fails otherwise)
- #3 committed straight to main with a conventional-commit message, and pushed