Skip to content

sources: preserve cursor when inbox persistence fails - #410

Open
fallintoplace wants to merge 3 commits into
ClickHouse:mainfrom
fallintoplace:fix/source-cursor-on-persistence-failure
Open

sources: preserve cursor when inbox persistence fails#410
fallintoplace wants to merge 3 commits into
ClickHouse:mainfrom
fallintoplace:fix/source-cursor-on-persistence-failure

Conversation

@fallintoplace

Copy link
Copy Markdown
Contributor

What changed

  • Fail closed when inbox persistence fails.
  • Let the database transaction roll back the full batch.
  • Return an ingestion error and keep the source cursor unchanged.
  • Retry the batch on the next healthy run. Existing dedup keeps retries safe.

Why

A bad row used to be logged and skipped. The rest of the batch still committed and the cursor moved forward, so the bad row could be lost permanently.

Tests

  • uv run pytest tests/test_source_runner_persistence.py tests/test_source_filters.py tests/test_source_resurface.py tests/test_db_atomicity.py
  • uv run python -m compileall -q nerve/db/sources.py nerve/sources/runner.py tests/test_source_runner_persistence.py

alex-clickhouse added a commit that referenced this pull request Sep 2, 2026
Review findings. Four of these let something be observed that the stated
policy says would not be.

A malformed allow list granted everything it looked like it disabled.
`allow_conversations: {"*": false}` survives the generic coercion as a dict,
and `list()` of a dict yields its keys — so a rule that reads as a switched-off
wildcard became `["*"]`. Mappings are now discarded outright. A bare scalar
still wraps to one pattern, because that is how `${VAR}` arrives and the
repo's own coercion invariant requires it; the first attempt at this fix
dropped it and `test_config_env` caught the regression.

Slack multi-person DMs were observed. They arrive as `channel_type="mpim"` on
a `G` id, so testing for `D` alone filed a group DM away as if it were a
channel. The check now reads the raw declared type rather than the caller's
derived one — that default turns an absent type into "channel", which is
exactly the ambiguity a `G` has to refuse rather than resolve.

Telegram chat titles granted access. A title is set by whoever runs the group,
so `allow_conversations: ["ops-room"]` admitted any group that named itself
that. Titles and usernames are now `self_set_names`, which the access module
already defines as deny-eligible only — the distinction existed and this code
was not using it. Other bots are skipped too, matching Slack's
`_is_another_app_talking`.

Telegram observation now also needs `include_unauthorized_senders`, off by
default. Its only seen-but-unanswered path is a sender the allowlist refused,
so collecting there is a sharper edge than Slack's "in the room but not
talking to me" and should be a decision rather than a side effect.

`ChannelSource` is now `<channel>:observed`. Sharing the bare `telegram` name
with the existing pull source meant sharing a cron job id and a cursor key:
the two runners evicted each other from the scheduler and then read each
other's cursor, one an integer and the other Telethon's JSON state.

Also: valid JSON that is not an object (`[]`) parsed and then raised in
`_to_record`, wedging the cursor behind it; an unusable `schedule` fell back
to nothing, leaving the spool filling with no drain; and the row-cap counter
was process-local, so a daemon restarting more often than it wrote 100 rows
never enforced the cap at all. Spooled text is now capped per row, since
capping row count alone bounds nothing in bytes.

The docs claimed a downstream inbox guardrail that was never wired. The drain
now re-applies the deny rules, which catches a conversation denied after its
messages were already spooled — deny-only, because these rules match id
fields and a name-based allow rule would fail closed and drop everything. The
threat model is restated honestly: nothing here inspects content, and no
allow/deny list separates a report from an instruction.

Not fixed here, and called out in the PR: SourceRunner advances its cursor
when an inbox write fails, which for observations loses them for good. That
is PR #410's subject and belongs there, not duplicated in this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alex-clickhouse added a commit that referenced this pull request Sep 3, 2026
Review findings. Four of these let something be observed that the stated
policy says would not be.

A malformed allow list granted everything it looked like it disabled.
`allow_conversations: {"*": false}` survives the generic coercion as a dict,
and `list()` of a dict yields its keys — so a rule that reads as a switched-off
wildcard became `["*"]`. Mappings are now discarded outright. A bare scalar
still wraps to one pattern, because that is how `${VAR}` arrives and the
repo's own coercion invariant requires it; the first attempt at this fix
dropped it and `test_config_env` caught the regression.

Slack multi-person DMs were observed. They arrive as `channel_type="mpim"` on
a `G` id, so testing for `D` alone filed a group DM away as if it were a
channel. The check now reads the raw declared type rather than the caller's
derived one — that default turns an absent type into "channel", which is
exactly the ambiguity a `G` has to refuse rather than resolve.

Telegram chat titles granted access. A title is set by whoever runs the group,
so `allow_conversations: ["ops-room"]` admitted any group that named itself
that. Titles and usernames are now `self_set_names`, which the access module
already defines as deny-eligible only — the distinction existed and this code
was not using it. Other bots are skipped too, matching Slack's
`_is_another_app_talking`.

Telegram observation now also needs `include_unauthorized_senders`, off by
default. Its only seen-but-unanswered path is a sender the allowlist refused,
so collecting there is a sharper edge than Slack's "in the room but not
talking to me" and should be a decision rather than a side effect.

`ChannelSource` is now `<channel>:observed`. Sharing the bare `telegram` name
with the existing pull source meant sharing a cron job id and a cursor key:
the two runners evicted each other from the scheduler and then read each
other's cursor, one an integer and the other Telethon's JSON state.

Also: valid JSON that is not an object (`[]`) parsed and then raised in
`_to_record`, wedging the cursor behind it; an unusable `schedule` fell back
to nothing, leaving the spool filling with no drain; and the row-cap counter
was process-local, so a daemon restarting more often than it wrote 100 rows
never enforced the cap at all. Spooled text is now capped per row, since
capping row count alone bounds nothing in bytes.

The docs claimed a downstream inbox guardrail that was never wired. The drain
now re-applies the deny rules, which catches a conversation denied after its
messages were already spooled — deny-only, because these rules match id
fields and a name-based allow rule would fail closed and drop everything. The
threat model is restated honestly: nothing here inspects content, and no
allow/deny list separates a report from an instruction.

Not fixed here, and called out in the PR: SourceRunner advances its cursor
when an inbox write fails, which for observations loses them for good. That
is PR #410's subject and belongs there, not duplicated in this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alex-clickhouse added a commit that referenced this pull request Sep 3, 2026
Review findings. Four of these let something be observed that the stated
policy says would not be.

A malformed allow list granted everything it looked like it disabled.
`allow_conversations: {"*": false}` survives the generic coercion as a dict,
and `list()` of a dict yields its keys — so a rule that reads as a switched-off
wildcard became `["*"]`. Mappings are now discarded outright. A bare scalar
still wraps to one pattern, because that is how `${VAR}` arrives and the
repo's own coercion invariant requires it; the first attempt at this fix
dropped it and `test_config_env` caught the regression.

Slack multi-person DMs were observed. They arrive as `channel_type="mpim"` on
a `G` id, so testing for `D` alone filed a group DM away as if it were a
channel. The check now reads the raw declared type rather than the caller's
derived one — that default turns an absent type into "channel", which is
exactly the ambiguity a `G` has to refuse rather than resolve.

Telegram chat titles granted access. A title is set by whoever runs the group,
so `allow_conversations: ["ops-room"]` admitted any group that named itself
that. Titles and usernames are now `self_set_names`, which the access module
already defines as deny-eligible only — the distinction existed and this code
was not using it. Other bots are skipped too, matching Slack's
`_is_another_app_talking`.

Telegram observation now also needs `include_unauthorized_senders`, off by
default. Its only seen-but-unanswered path is a sender the allowlist refused,
so collecting there is a sharper edge than Slack's "in the room but not
talking to me" and should be a decision rather than a side effect.

`ChannelSource` is now `<channel>:observed`. Sharing the bare `telegram` name
with the existing pull source meant sharing a cron job id and a cursor key:
the two runners evicted each other from the scheduler and then read each
other's cursor, one an integer and the other Telethon's JSON state.

Also: valid JSON that is not an object (`[]`) parsed and then raised in
`_to_record`, wedging the cursor behind it; an unusable `schedule` fell back
to nothing, leaving the spool filling with no drain; and the row-cap counter
was process-local, so a daemon restarting more often than it wrote 100 rows
never enforced the cap at all. Spooled text is now capped per row, since
capping row count alone bounds nothing in bytes.

The docs claimed a downstream inbox guardrail that was never wired. The drain
now re-applies the deny rules, which catches a conversation denied after its
messages were already spooled — deny-only, because these rules match id
fields and a name-based allow rule would fail closed and drop everything. The
threat model is restated honestly: nothing here inspects content, and no
allow/deny list separates a report from an instruction.

Not fixed here, and called out in the PR: SourceRunner advances its cursor
when an inbox write fails, which for observations loses them for good. That
is PR #410's subject and belongs there, not duplicated in this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alex-clickhouse added a commit that referenced this pull request Sep 3, 2026
Review findings. Four of these let something be observed that the stated
policy says would not be.

A malformed allow list granted everything it looked like it disabled.
`allow_conversations: {"*": false}` survives the generic coercion as a dict,
and `list()` of a dict yields its keys — so a rule that reads as a switched-off
wildcard became `["*"]`. Mappings are now discarded outright. A bare scalar
still wraps to one pattern, because that is how `${VAR}` arrives and the
repo's own coercion invariant requires it; the first attempt at this fix
dropped it and `test_config_env` caught the regression.

Slack multi-person DMs were observed. They arrive as `channel_type="mpim"` on
a `G` id, so testing for `D` alone filed a group DM away as if it were a
channel. The check now reads the raw declared type rather than the caller's
derived one — that default turns an absent type into "channel", which is
exactly the ambiguity a `G` has to refuse rather than resolve.

Telegram chat titles granted access. A title is set by whoever runs the group,
so `allow_conversations: ["ops-room"]` admitted any group that named itself
that. Titles and usernames are now `self_set_names`, which the access module
already defines as deny-eligible only — the distinction existed and this code
was not using it. Other bots are skipped too, matching Slack's
`_is_another_app_talking`.

Telegram observation now also needs `include_unauthorized_senders`, off by
default. Its only seen-but-unanswered path is a sender the allowlist refused,
so collecting there is a sharper edge than Slack's "in the room but not
talking to me" and should be a decision rather than a side effect.

`ChannelSource` is now `<channel>:observed`. Sharing the bare `telegram` name
with the existing pull source meant sharing a cron job id and a cursor key:
the two runners evicted each other from the scheduler and then read each
other's cursor, one an integer and the other Telethon's JSON state.

Also: valid JSON that is not an object (`[]`) parsed and then raised in
`_to_record`, wedging the cursor behind it; an unusable `schedule` fell back
to nothing, leaving the spool filling with no drain; and the row-cap counter
was process-local, so a daemon restarting more often than it wrote 100 rows
never enforced the cap at all. Spooled text is now capped per row, since
capping row count alone bounds nothing in bytes.

The docs claimed a downstream inbox guardrail that was never wired. The drain
now re-applies the deny rules, which catches a conversation denied after its
messages were already spooled — deny-only, because these rules match id
fields and a name-based allow rule would fail closed and drop everything. The
threat model is restated honestly: nothing here inspects content, and no
allow/deny list separates a report from an instruction.

Not fixed here, and called out in the PR: SourceRunner advances its cursor
when an inbox write fails, which for observations loses them for good. That
is PR #410's subject and belongs there, not duplicated in this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alex-clickhouse added a commit that referenced this pull request Sep 3, 2026
Review findings. Four of these let something be observed that the stated
policy says would not be.

A malformed allow list granted everything it looked like it disabled.
`allow_conversations: {"*": false}` survives the generic coercion as a dict,
and `list()` of a dict yields its keys — so a rule that reads as a switched-off
wildcard became `["*"]`. Mappings are now discarded outright. A bare scalar
still wraps to one pattern, because that is how `${VAR}` arrives and the
repo's own coercion invariant requires it; the first attempt at this fix
dropped it and `test_config_env` caught the regression.

Slack multi-person DMs were observed. They arrive as `channel_type="mpim"` on
a `G` id, so testing for `D` alone filed a group DM away as if it were a
channel. The check now reads the raw declared type rather than the caller's
derived one — that default turns an absent type into "channel", which is
exactly the ambiguity a `G` has to refuse rather than resolve.

Telegram chat titles granted access. A title is set by whoever runs the group,
so `allow_conversations: ["ops-room"]` admitted any group that named itself
that. Titles and usernames are now `self_set_names`, which the access module
already defines as deny-eligible only — the distinction existed and this code
was not using it. Other bots are skipped too, matching Slack's
`_is_another_app_talking`.

Telegram observation now also needs `include_unauthorized_senders`, off by
default. Its only seen-but-unanswered path is a sender the allowlist refused,
so collecting there is a sharper edge than Slack's "in the room but not
talking to me" and should be a decision rather than a side effect.

`ChannelSource` is now `<channel>:observed`. Sharing the bare `telegram` name
with the existing pull source meant sharing a cron job id and a cursor key:
the two runners evicted each other from the scheduler and then read each
other's cursor, one an integer and the other Telethon's JSON state.

Also: valid JSON that is not an object (`[]`) parsed and then raised in
`_to_record`, wedging the cursor behind it; an unusable `schedule` fell back
to nothing, leaving the spool filling with no drain; and the row-cap counter
was process-local, so a daemon restarting more often than it wrote 100 rows
never enforced the cap at all. Spooled text is now capped per row, since
capping row count alone bounds nothing in bytes.

The docs claimed a downstream inbox guardrail that was never wired. The drain
now re-applies the deny rules, which catches a conversation denied after its
messages were already spooled — deny-only, because these rules match id
fields and a name-based allow rule would fail closed and drop everything. The
threat model is restated honestly: nothing here inspects content, and no
allow/deny list separates a report from an instruction.

Not fixed here, and called out in the PR: SourceRunner advances its cursor
when an inbox write fails, which for observations loses them for good. That
is PR #410's subject and belongs there, not duplicated in this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alex-clickhouse added a commit that referenced this pull request Sep 4, 2026
Review findings. Four of these let something be observed that the stated
policy says would not be.

A malformed allow list granted everything it looked like it disabled.
`allow_conversations: {"*": false}` survives the generic coercion as a dict,
and `list()` of a dict yields its keys — so a rule that reads as a switched-off
wildcard became `["*"]`. Mappings are now discarded outright. A bare scalar
still wraps to one pattern, because that is how `${VAR}` arrives and the
repo's own coercion invariant requires it; the first attempt at this fix
dropped it and `test_config_env` caught the regression.

Slack multi-person DMs were observed. They arrive as `channel_type="mpim"` on
a `G` id, so testing for `D` alone filed a group DM away as if it were a
channel. The check now reads the raw declared type rather than the caller's
derived one — that default turns an absent type into "channel", which is
exactly the ambiguity a `G` has to refuse rather than resolve.

Telegram chat titles granted access. A title is set by whoever runs the group,
so `allow_conversations: ["ops-room"]` admitted any group that named itself
that. Titles and usernames are now `self_set_names`, which the access module
already defines as deny-eligible only — the distinction existed and this code
was not using it. Other bots are skipped too, matching Slack's
`_is_another_app_talking`.

Telegram observation now also needs `include_unauthorized_senders`, off by
default. Its only seen-but-unanswered path is a sender the allowlist refused,
so collecting there is a sharper edge than Slack's "in the room but not
talking to me" and should be a decision rather than a side effect.

`ChannelSource` is now `<channel>:observed`. Sharing the bare `telegram` name
with the existing pull source meant sharing a cron job id and a cursor key:
the two runners evicted each other from the scheduler and then read each
other's cursor, one an integer and the other Telethon's JSON state.

Also: valid JSON that is not an object (`[]`) parsed and then raised in
`_to_record`, wedging the cursor behind it; an unusable `schedule` fell back
to nothing, leaving the spool filling with no drain; and the row-cap counter
was process-local, so a daemon restarting more often than it wrote 100 rows
never enforced the cap at all. Spooled text is now capped per row, since
capping row count alone bounds nothing in bytes.

The docs claimed a downstream inbox guardrail that was never wired. The drain
now re-applies the deny rules, which catches a conversation denied after its
messages were already spooled — deny-only, because these rules match id
fields and a name-based allow rule would fail closed and drop everything. The
threat model is restated honestly: nothing here inspects content, and no
allow/deny list separates a report from an instruction.

Not fixed here, and called out in the PR: SourceRunner advances its cursor
when an inbox write fails, which for observations loses them for good. That
is PR #410's subject and belongs there, not duplicated in this branch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant