**COLLECTING BRANCH** Per-user data ownership: subscriptions, aggregates, OPML #66

Manually merged
marvin8 merged 19 commits from feat/issue-65-data-ownership into feat/multiuser 2026-10-04 02:30:46 +00:00
Collaborator

Implements Effort 2 (data ownership) of the Phase 6 multi-user effort.

Refs #65
Refs #25 (non-closing — only the eventual final PR into main carries Closes)

Base branch: feat/multiuser (collecting branch — this PR stacks on it; it is NOT based on main).

What this does

  • Feeds become per-user via a global feeds table plus feed_subscriptions(user_id, feed_id): duplicate subscriptions fetch/store once; the last subscriber out triggers orphan-feed GC (FK cascades remove articles, media, tag links, read state).
  • aggregates.user_id: fully independent per-user topic clusters; cross-user aggregate ids 404 on view and mark routes.
  • LLM aggregation loops users with per-user watermarks plus a subscription-backlog rule; users with nothing new cost zero LLM calls; one user's API failure never blocks another's run.
  • Reader, feed management, discovery, OPML import/export all scope to the session user; feed deletion becomes unsubscribe (button + route).
  • CLI add-feed/list-feeds gain --user (default default); docs/cli.md and docs/reader.md updated in the same PR.
  • Alembic migration 20261003_data_ownership: seeds default when absent (worker-only installs), backfills all feeds and aggregates to user 1, rebuilds aggregates with user_id NOT NULL; downgrade reverses.

Verification

  • Full suite 540 passing on Python 3.12, 3.13, and 3.14; uv run nox 9/9 sessions (zaojun under --compat-ok).
  • Six task-level reviews plus a final whole-branch review; one fix wave (01551c3 atomicity, 821d00a docs/annotation hygiene).

Note for review: the nox pytest-3.12/13/14 sessions collect 0 tests via tryke test (pre-existing noxfile.py behaviour, unchanged here) — real cross-interpreter evidence is the direct pytest runs.

Implements Effort 2 (data ownership) of the Phase 6 multi-user effort. Refs #65 Refs #25 (non-closing — only the eventual final PR into `main` carries `Closes`) **Base branch: `feat/multiuser`** (collecting branch — this PR stacks on it; it is NOT based on `main`). ## What this does - Feeds become per-user via a global `feeds` table plus `feed_subscriptions(user_id, feed_id)`: duplicate subscriptions fetch/store once; the last subscriber out triggers orphan-feed GC (FK cascades remove articles, media, tag links, read state). - `aggregates.user_id`: fully independent per-user topic clusters; cross-user aggregate ids 404 on view and mark routes. - LLM aggregation loops users with per-user watermarks plus a subscription-backlog rule; users with nothing new cost zero LLM calls; one user's API failure never blocks another's run. - Reader, feed management, discovery, OPML import/export all scope to the session user; feed deletion becomes unsubscribe (button + route). - CLI `add-feed`/`list-feeds` gain `--user` (default `default`); `docs/cli.md` and `docs/reader.md` updated in the same PR. - Alembic migration `20261003_data_ownership`: seeds `default` when absent (worker-only installs), backfills all feeds and aggregates to user 1, rebuilds `aggregates` with `user_id NOT NULL`; downgrade reverses. ## Verification - Full suite 540 passing on Python 3.12, 3.13, and 3.14; `uv run nox` 9/9 sessions (zaojun under `--compat-ok`). - Six task-level reviews plus a final whole-branch review; one fix wave (01551c3 atomicity, 821d00a docs/annotation hygiene). Note for review: the nox `pytest-3.12/13/14` sessions collect 0 tests via `tryke test` (pre-existing `noxfile.py` behaviour, unchanged here) — real cross-interpreter evidence is the direct pytest runs.
forgejo-actions left a comment

WuMing

Found 5 issue(s). See inline comments below.

## WuMing Found **5** issue(s). See inline comments below.
@ -23,3 +23,2 @@
deepseek_key: ${{ secrets.wuming_deepseek_key }}
agents: code:deepseek-v4-pro,config:deepseek-v4-flash,docs:deepseek-v4-flash,shell:deepseek-v4-flash,security:deepseek-v4-pro,tests:deepseek-v4-flash
max_diff_lines: "2000"
agents: code:deepseek-flash,config:deepseek-flash,docs:deepseek-flash,shell:deepseek-flash,security:deepseek-flash,tests:deepseek-flash

config [MEDIUM]

All review agents, including security and code, were collapsed to deepseek-flash. Previously security and code used deepseek-v4-pro, the stronger model for those roles; downgrading the security/code reviewers to the cheaper flash model weakens exactly the checks this pipeline exists to perform. Also confirm deepseek-flash is a valid model id in the zaojun registry (the other entries previously carried a v4- prefix, e.g. deepseek-v4-flash), otherwise the review job will fail or silently fall back. If the downgrade is intentional for cost reasons, keep security:deepseek-v4-pro and document the change.

**config** [MEDIUM] All review agents, including `security` and `code`, were collapsed to `deepseek-flash`. Previously `security` and `code` used `deepseek-v4-pro`, the stronger model for those roles; downgrading the security/code reviewers to the cheaper flash model weakens exactly the checks this pipeline exists to perform. Also confirm `deepseek-flash` is a valid model id in the zaojun registry (the other entries previously carried a `v4-` prefix, e.g. `deepseek-v4-flash`), otherwise the review job will fail or silently fall back. If the downgrade is intentional for cost reasons, keep `security:deepseek-v4-pro` and document the change. <!-- wuming:sha256:667e57d3b3463a692d77129ff6215514517860c870d0b586829efda6855c0065 -->
marvin8 marked this conversation as resolved
@ -24,2 +24,2 @@
agents: code:deepseek-v4-pro,config:deepseek-v4-flash,docs:deepseek-v4-flash,shell:deepseek-v4-flash,security:deepseek-v4-pro,tests:deepseek-v4-flash
max_diff_lines: "2000"
agents: code:deepseek-flash,config:deepseek-flash,docs:deepseek-flash,shell:deepseek-flash,security:deepseek-flash,tests:deepseek-flash
max_diff_lines: "5000"

config [LOW]

max_diff_lines is raised from 2000 to 5000. This is the size of the largest diff handed to the review agents; tripling it increases the chance the reviewer runs out of context and produces shallow or skipped findings, and it increases token spend. Unless the previous limit was rejecting legitimate diffs, keep 2000 (or split large PRs); if 5000 is required, note that reviewers on large diffs will be less thorough.

**config** [LOW] `max_diff_lines` is raised from 2000 to 5000. This is the size of the largest diff handed to the review agents; tripling it increases the chance the reviewer runs out of context and produces shallow or skipped findings, and it increases token spend. Unless the previous limit was rejecting legitimate diffs, keep 2000 (or split large PRs); if 5000 is required, note that reviewers on large diffs will be less thorough. <!-- wuming:sha256:2f60fa86bf1d313539ef527a6f2dcab07d7a5d1e9a98bc4e37b37bc9993451f5 -->
marvin8 marked this conversation as resolved
docs/cli.md Outdated
@ -64,10 +67,28 @@ yunjin add-feed URL --title "Feed Title" [--description "..."] [--interval MINUT
| `--title` | *required* | Display name for the feed. |

docs [MEDIUM]

The preceding text (lines 56–59) now states that feed rows are shared between accounts and that subscribing a second account to the same URL reuses the existing feed, but the option table still describes --title simply as "Display name for the feed." With a shared feed row it is unclear what this flag does on reuse: does it rename the feed for every other account already subscribed to it, or is it ignored when the feed already exists? The same ambiguity applies to --interval on line 69 (a per-feed refresh interval cannot be changed by a later subscriber without affecting everyone). Add an explicit note, e.g. "When the feed already exists, --title/--interval apply only if supplied by the first subscriber; later subscriptions reuse the existing feed metadata," or state whatever the actual behaviour is.

**docs** [MEDIUM] The preceding text (lines 56–59) now states that feed rows are shared between accounts and that subscribing a second account to the same URL reuses the existing feed, but the option table still describes `--title` simply as "Display name for the feed." With a shared feed row it is unclear what this flag does on reuse: does it rename the feed for every other account already subscribed to it, or is it ignored when the feed already exists? The same ambiguity applies to `--interval` on line 69 (a per-feed refresh interval cannot be changed by a later subscriber without affecting everyone). Add an explicit note, e.g. "When the feed already exists, `--title`/`--interval` apply only if supplied by the first subscriber; later subscriptions reuse the existing feed metadata," or state whatever the actual behaviour is. <!-- wuming:sha256:a0fd6f99ef925d5146df3fc905828a1f29413fa3be01ea5d7b0654180c873ee7 -->
pyproject.toml Outdated
@ -96,0 +96,4 @@
[[tool.zaojun.license-ignore]]
package = "waitress"
reason = "Uses Zope Public License (ZPL) Version 2.1 which is close-ish to MIT, so ok"

config [LOW]

A license exception is added to bypass check-licenses for waitress. The stated justification is inaccurate: Zope Public License 2.1 is not 'close-ish to MIT' — it is a copyleft-adjacent, GPL-compatible license with notice/redistribution obligations, distinct from MIT/BSD. Since waitress is a runtime dependency, verify the exception is actually acceptable for the project's distribution terms (and record the real rationale/approval) rather than relying on an incorrect description of the license.

**config** [LOW] A license exception is added to bypass `check-licenses` for `waitress`. The stated justification is inaccurate: Zope Public License 2.1 is not 'close-ish to MIT' — it is a copyleft-adjacent, GPL-compatible license with notice/redistribution obligations, distinct from MIT/BSD. Since waitress is a runtime dependency, verify the exception is actually acceptable for the project's distribution terms (and record the real rationale/approval) rather than relying on an incorrect description of the license. <!-- wuming:sha256:c526a787acc97b7347e3bd7106c9706613ad3af844b726f19f10bd971dd2abe0 -->
@ -211,3 +214,4 @@
aggregate = agg_db.create_aggregate(db_conn, user_id=_test_user_id(db_conn), summary="")
response = auth_client.get(f"/aggregate/{aggregate.id}")
assert response.status_code == 200

tests [MEDIUM]

test_unsubscribe_feed only asserts the redirect chain resolves to 200; it never verifies the effect of the route. A no-op handler that still redirects to /feeds would pass this test. Add a post-condition assertion (e.g. subscriptions_db.user_subscribes(db_conn, _test_user_id(db_conn), feed.id) is False, or that the feed row is gone for the last subscriber) so the unsubscribe behavior itself is exercised.

**tests** [MEDIUM] `test_unsubscribe_feed` only asserts the redirect chain resolves to 200; it never verifies the effect of the route. A no-op handler that still redirects to /feeds would pass this test. Add a post-condition assertion (e.g. `subscriptions_db.user_subscribes(db_conn, _test_user_id(db_conn), feed.id) is False`, or that the feed row is gone for the last subscriber) so the unsubscribe behavior itself is exercised. <!-- wuming:sha256:f9aa23f955932ca45398c8f45569965f954f1a1c94969160ae21d137afe15f22 -->
✅ Pin unsubscribe effect and document feed-reuse metadata
All checks were successful
/ gitleaks (pull_request) Successful in 38s
/ pr-review (pull_request) Successful in 1m28s
/ checks (pull_request) Successful in 1m48s
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ container (pull_request) Successful in 1m45s
819cb9827f
forgejo-actions left a comment

WuMing

Found 3 issue(s). See inline comments below.

## WuMing Found **3** issue(s). See inline comments below.
docs/cli.md Outdated
@ -54,3 +54,3 @@
### `add-feed`
Add a new RSS/Atom feed.
Subscribe an account to a new RSS/Atom feed, creating the feed row when it

docs [LOW]

"Subscribe an account to a new RSS/Atom feed" is misleading, since the very next sentence describes subscribing to a feed that already exists (the row is reused). Suggest dropping "new": "Subscribe an account to an RSS/Atom feed, creating the feed row when it does not exist yet."

**docs** [LOW] "Subscribe an account to a new RSS/Atom feed" is misleading, since the very next sentence describes subscribing to a feed that already exists (the row is reused). Suggest dropping "new": "Subscribe an account to an RSS/Atom feed, creating the feed row when it does not exist yet." <!-- wuming:sha256:d9811828ee42676b81cb38bfa40e125ccdb5a72b246e25dfdfee63ba60a0c388 -->
docs/cli.md Outdated
@ -67,0 +72,4 @@
| `--user` | `default` | Account that owns the subscription. |
An unknown `--user` name logs an error and exits with code 1 without creating
a feed. If the account already subscribes to the URL, the command logs an

docs [MEDIUM]

This sentence contradicts itself and the surrounding semantics: a duplicate subscription "logs an error but exits successfully" (i.e. exit code 0), while the preceding sentence says an unknown --user "logs an error and exits with code 1". Readers writing scripts or CI checks cannot tell from the docs whether a duplicate is a failure. Please state the exact exit code explicitly (e.g. "...prints a notice that the account already subscribes and exits 0") and reword "logs an error" to "logs a notice/warning" so the two cases are clearly distinguishable. Confirm the wording matches the actual implementation.

**docs** [MEDIUM] This sentence contradicts itself and the surrounding semantics: a duplicate subscription "logs an error but exits successfully" (i.e. exit code 0), while the preceding sentence says an unknown `--user` "logs an error and exits with code 1". Readers writing scripts or CI checks cannot tell from the docs whether a duplicate is a failure. Please state the exact exit code explicitly (e.g. "...prints a notice that the account already subscribes and exits 0") and reword "logs an error" to "logs a notice/warning" so the two cases are clearly distinguishable. Confirm the wording matches the actual implementation. <!-- wuming:sha256:d0258438c951d3fc90a829e6d4b09f6984a600ecde582b506a99c67c7c050e09 -->
@ -541,3 +596,4 @@
agg_id = self._resolve_topic_aggregate_id(topic, user_id)
# Link articles to this aggregate
for article_id in topic.article_ids:

security [MEDIUM]

A03/A01: These article ids come straight from the untrusted LLM response (topic.article_ids) and are linked with no check that each id belongs to an article in one of the user's subscribed feeds. Article titles/content from feeds are attacker-influenced data fed into the prompt, so a prompt-injection payload in a malicious feed could name another user's (sequential, guessable) article id and have it linked into this user's aggregate, which the reader then renders for the attacker. Aggregate ids are ownership-checked in _resolve_topic_aggregate_id, but article ids are not; validate every article_id against articles joined to the user's feed_subscriptions (or scope the resulting list to the user's feeds) before calling add_article_to_aggregate.

**security** [MEDIUM] A03/A01: These article ids come straight from the untrusted LLM response (`topic.article_ids`) and are linked with no check that each id belongs to an article in one of the user's subscribed feeds. Article titles/content from feeds are attacker-influenced data fed into the prompt, so a prompt-injection payload in a malicious feed could name another user's (sequential, guessable) article id and have it linked into this user's aggregate, which the reader then renders for the attacker. Aggregate ids are ownership-checked in `_resolve_topic_aggregate_id`, but article ids are not; validate every `article_id` against `articles` joined to the user's `feed_subscriptions` (or scope the resulting list to the user's feeds) before calling `add_article_to_aggregate`. <!-- wuming:sha256:6e95ea4b0b8008e252e7c7dd73bdd4da4932873565d423b24aacfb5015234e1a -->
Author
Collaborator

.forgejo/workflows/ci.yml

config [LOW] — see full comment at the link.

🔴 Declined — this is the repository owner's own deliberate change in this PR (commit 4a2457d): the cap was raised precisely so that large legitimate diffs (including this one) get reviewed instead of being skipped. The thoroughness-vs-coverage trade-off at 5000 lines is accepted by the operator.

[`.forgejo/workflows/ci.yml`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4157) > **config** [LOW] — see full comment at the link. 🔴 Declined — this is the repository owner's own deliberate change in this PR (commit `4a2457d`): the cap was raised precisely so that large legitimate diffs (including this one) get reviewed instead of being skipped. The thoroughness-vs-coverage trade-off at 5000 lines is accepted by the operator.
Author
Collaborator

.forgejo/workflows/ci.yml

config [MEDIUM] — see full comment at the link.

🔴 Declined — same commit (4a2457d), the operator's deliberate cost choice, made with knowledge of the previous v4-pro roles. On the factual question: the pr-review job completed successfully on this PR and produced the very review wave this reply is part of, which operationally confirms the deepseek-flash ids resolve in this pipeline.

[`.forgejo/workflows/ci.yml`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4156) > **config** [MEDIUM] — see full comment at the link. 🔴 Declined — same commit (`4a2457d`), the operator's deliberate cost choice, made with knowledge of the previous v4-pro roles. On the factual question: the `pr-review` job completed successfully on this PR and produced the very review wave this reply is part of, which operationally confirms the `deepseek-flash` ids resolve in this pipeline.
Author
Collaborator

pyproject.toml

config [LOW] — see full comment at the link.

🔴 Declined — the exception and its reason string are the repository owner's own commit (a78fe97); license acceptance for this project is the operator's decision. The characterization wording is theirs to adjust if they wish; no code change accompanies this comment.

[`pyproject.toml`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4158) > **config** [LOW] — see full comment at the link. 🔴 Declined — the exception and its reason string are the repository owner's own commit (`a78fe97`); license acceptance for this project is the operator's decision. The characterization wording is theirs to adjust if they wish; no code change accompanies this comment.
Author
Collaborator

docs/cli.md

docs [MEDIUM] — see full comment at the link.

✅ Fixed in commit 819cb98 — docs/cli.md now states that the first subscriber's --title/--description/--interval define the shared feed row and a later subscriber's values are ignored on reuse (matching subscribe_or_create's actual behaviour).

[`docs/cli.md`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4159) > **docs** [MEDIUM] — see full comment at the link. ✅ Fixed in commit `819cb98` — `docs/cli.md` now states that the first subscriber's `--title`/`--description`/`--interval` define the shared feed row and a later subscriber's values are ignored on reuse (matching `subscribe_or_create`'s actual behaviour).
Author
Collaborator

tests/test_web.py

tests [MEDIUM] — see full comment at the link.

✅ Fixed in commit 819cb98 — test_unsubscribe_feed now asserts the post-conditions: user_subscribes(...) is False for the session user AND the feed row is gone (sole-subscriber GC removed it).

[`tests/test_web.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4160) > **tests** [MEDIUM] — see full comment at the link. ✅ Fixed in commit `819cb98` — `test_unsubscribe_feed` now asserts the post-conditions: `user_subscribes(...) is False` for the session user AND the feed row is gone (sole-subscriber GC removed it).
🔒 Scope aggregate article links to the aggregating user
All checks were successful
/ gitleaks (pull_request) Successful in 22s
/ pr-review (pull_request) Successful in 1m21s
/ checks (pull_request) Successful in 1m58s
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ container (pull_request) Successful in 1m43s
c851e8af5f
Author
Collaborator

docs/cli.md

docs [MEDIUM] — see full comment at the link.

✅ Fixed in commit c851e8a — the duplicate-subscription path now logs a warning (logger.warning) and docs/cli.md states it explicitly: "logs a warning and exits with code 0" (vs the unknown-user error at exit code 1). The wording now matches the implementation, and a new test (test_add_feed_already_subscribed_warns_and_exits_0) pins the warning level, message, exit-less return, and that no second feed row is created.

[`docs/cli.md`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4163) > **docs** [MEDIUM] — see full comment at the link. ✅ Fixed in commit `c851e8a` — the duplicate-subscription path now logs a **warning** (`logger.warning`) and `docs/cli.md` states it explicitly: "logs a warning and exits with code 0" (vs the unknown-user error at exit code 1). The wording now matches the implementation, and a new test (`test_add_feed_already_subscribed_warns_and_exits_0`) pins the warning level, message, exit-less return, and that no second feed row is created.
Author
Collaborator

docs/cli.md

docs [LOW] — see full comment at the link.

✅ Fixed in commit c851e8a — the sentence now reads "Subscribe an account to an RSS/Atom feed, creating the feed row when it does not exist yet."

[`docs/cli.md`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4164) > **docs** [LOW] — see full comment at the link. ✅ Fixed in commit `c851e8a` — the sentence now reads "Subscribe an account to an RSS/Atom feed, creating the feed row when it does not exist yet."
Author
Collaborator

src/yunjin/services/llm_aggregator.py

security [MEDIUM] — see full comment at the link.

✅ Fixed in commit c851e8a — _apply_topics now takes allowed_article_ids (the set of article ids actually sent in this user's prompt: new articles for aggregate(), all subscribed-feed articles for refresh()). Any topic article id outside the set — foreign-owned or hallucinated — is skipped with a warning and never linked, and the per-aggregate linked count reports only articles actually linked. Pinned by test_apply_topics_ignores_article_ids_outside_users_feeds (a topic naming user 2's article cannot link it into user 1's aggregate).

[`src/yunjin/services/llm_aggregator.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4165) > **security** [MEDIUM] — see full comment at the link. ✅ Fixed in commit `c851e8a` — `_apply_topics` now takes `allowed_article_ids` (the set of article ids actually sent in this user's prompt: new articles for `aggregate()`, all subscribed-feed articles for `refresh()`). Any topic article id outside the set — foreign-owned or hallucinated — is skipped with a warning and never linked, and the per-aggregate linked count reports only articles actually linked. Pinned by `test_apply_topics_ignores_article_ids_outside_users_feeds` (a topic naming user 2's article cannot link it into user 1's aggregate).
forgejo-actions left a comment

WuMing

Found 1 issue(s). See inline comments below.

## WuMing Found **1** issue(s). See inline comments below.
@ -0,0 +20,4 @@
def test_feed_subscriptions_table_shape(db_conn):
columns = {row[1] for row in db_conn.execute("PRAGMA table_info(feed_subscriptions)")}
assert {"id", "user_id", "feed_id", "created_at"} <= columns
unique_index_columns = [row[2] for row in db_conn.execute("PRAGMA index_list(feed_subscriptions)") if row[3]]

tests [MEDIUM]

This assertion does not verify what its message claims. PRAGMA index_list(feed_subscriptions) returns (seq, name, unique, origin, partial), so row[2] is the boolean unique flag and row[3] is origin (truthy for every index, 'u' for a UNIQUE constraint and 'c' for a plain CREATE INDEX). The list therefore just collects unique flags for any index, and assert unique_index_columns only proves that at least one index exists on the table — it would still pass if the UNIQUE(user_id, feed_id) constraint were dropped and only idx_feed_subscriptions_feed_id remained, giving false confidence in the constraint that makes subscribe() idempotent. Inspect the indexed columns instead, e.g. find the index whose unique flag is 1 and assert {r[2] for r in PRAGMA index_info(<that name>)} covers {user_id, feed_id}, or query PRAGMA index_list for a unique index and then check its columns via PRAGMA index_info.

**tests** [MEDIUM] This assertion does not verify what its message claims. `PRAGMA index_list(feed_subscriptions)` returns (seq, name, unique, origin, partial), so `row[2]` is the boolean `unique` flag and `row[3]` is `origin` (truthy for every index, 'u' for a UNIQUE constraint and 'c' for a plain CREATE INDEX). The list therefore just collects unique flags for *any* index, and `assert unique_index_columns` only proves that at least one index exists on the table — it would still pass if the `UNIQUE(user_id, feed_id)` constraint were dropped and only `idx_feed_subscriptions_feed_id` remained, giving false confidence in the constraint that makes subscribe() idempotent. Inspect the indexed columns instead, e.g. find the index whose `unique` flag is 1 and assert `{r[2] for r in PRAGMA index_info(<that name>)}` covers `{user_id, feed_id}`, or query `PRAGMA index_list` for a unique index and then check its columns via `PRAGMA index_info`. <!-- wuming:sha256:45d4c3ef29178f9cc034d24ca34ed7561e6d99e4a47f0c50e905fbcbd632a187 -->
Author
Collaborator

tests/test_migration_data_ownership.py

tests [MEDIUM] — see full comment at the link.

✅ Fixed in commit 819d-series — the assertion now collects indexes whose unique flag is set and verifies via PRAGMA index_info that one of them indexes exactly (user_id, feed_id). Verified discriminating: it fails on a degraded schema with only the plain feed_id index and passes on the real one (check script output recorded in the PR timeline).

[`tests/test_migration_data_ownership.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4176) > **tests** [MEDIUM] — see full comment at the link. ✅ Fixed in commit `819d`-series — the assertion now collects indexes whose unique flag is set and verifies via `PRAGMA index_info` that one of them indexes exactly (user_id, feed_id). Verified discriminating: it fails on a degraded schema with only the plain feed_id index and passes on the real one (check script output recorded in the PR timeline).
✅ Assert the subscription UNIQUE constraint by indexed columns
All checks were successful
/ gitleaks (pull_request) Successful in 23s
/ pr-review (pull_request) Successful in 1m21s
/ checks (pull_request) Successful in 2m2s
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ container (pull_request) Successful in 1m59s
5439ead0ca
Author
Collaborator

Correction to my reply above: the fix commit is 5439ead (:white_check_mark: Assert the subscription UNIQUE constraint by indexed columns) — the hash written in the previous comment was wrong; this one is authoritative.

Correction to my reply above: the fix commit is `5439ead` (`:white_check_mark: Assert the subscription UNIQUE constraint by indexed columns`) — the hash written in the previous comment was wrong; this one is authoritative.
⏪ Run the nox pytest session with plain pytest
All checks were successful
/ gitleaks (pull_request) Successful in 24s
/ pr-review (pull_request) Successful in 1m35s
/ checks (pull_request) Successful in 18m27s
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ container (pull_request) Successful in 1m42s
bcbf9e80fc
forgejo-actions left a comment

WuMing

Found 1 issue(s). See inline comments below.

## WuMing Found **1** issue(s). See inline comments below.
@ -94,2 +94,4 @@
min-age = 7
check-licenses = true
[[tool.zaojun.license-ignore]]

config [LOW]

A new license-ignore entry suppresses the check-licenses = true gate for waitress (line 98), but the stated reason on line 99 ("close-ish to MIT, so ok") is informal and not a verifiable compatibility argument. Record waitress's actual SPDX identifier (ZPL-2.1) and the concrete basis for accepting it (it is a permissive, OSI-approved licence, not MIT), or resolve/allow the dependency through the normal licence policy rather than bypassing the check. Also note the version actually pinned in this file is waitress~=3.0.2, so the ignore should be reviewed whenever it is next bumped.

**config** [LOW] A new `license-ignore` entry suppresses the `check-licenses = true` gate for `waitress` (line 98), but the stated reason on line 99 ("close-ish to MIT, so ok") is informal and not a verifiable compatibility argument. Record waitress's actual SPDX identifier (ZPL-2.1) and the concrete basis for accepting it (it is a permissive, OSI-approved licence, not MIT), or resolve/allow the dependency through the normal licence policy rather than bypassing the check. Also note the version actually pinned in this file is `waitress~=3.0.2`, so the ignore should be reviewed whenever it is next bumped. <!-- wuming:sha256:020167f891a0b83bd24c1132c357394489c3298f4f25c2dea752a7325ab32700 -->
marvin8 marked this conversation as resolved
Author
Collaborator

Wave 4 on bcbf9e8 — one re-raised finding, the rest already answered

🔴 Duplicate — waitress licence-ignore rationale (pyproject.toml)

A new license-ignore entry suppresses the check-licenses gate for waitress…

Same substance as the wave-1 finding, already declined there: the exception and its reason string are the repository owner's own commit (a78fe97); the licence-acceptance decision and any rewording of that reason are theirs to make, not this branch's. (For what it's worth, the wave-4 wording — ZPL-2.1, permissive, OSI-approved — is a more accurate description than the original "close-ish to MIT"; the owner may want to lift it into the reason string.)

Already answered in waves 1–3, no further action: diff cap 5000 · deepseek-flash agents · --title/--interval reuse · duplicate exit code · "new" wording · article-id scoping · UNIQUE assertion · unsubscribe post-condition.

### Wave 4 on `bcbf9e8` — one re-raised finding, the rest already answered 🔴 **Duplicate — waitress licence-ignore rationale** ([`pyproject.toml`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4197)) > A new `license-ignore` entry suppresses the `check-licenses` gate for waitress… Same substance as [`the wave-1 finding`]( https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4158), already declined there: the exception and its reason string are the repository owner's own commit (`a78fe97`); the licence-acceptance decision and any rewording of that reason are theirs to make, not this branch's. (For what it's worth, the wave-4 wording — ZPL-2.1, permissive, OSI-approved — is a more accurate description than the original "close-ish to MIT"; the owner may want to lift it into the reason string.) Already answered in waves 1–3, no further action: [diff cap 5000](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4167) · [deepseek-flash agents](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4168) · [--title/--interval reuse](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4170) · [duplicate exit code](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4173) · ["new" wording](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4174) · [article-id scoping](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4175) · [UNIQUE assertion](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4178) · [unsubscribe post-condition](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4171).
📝 Reword the waitress licence-ignore rationale
All checks were successful
/ gitleaks (pull_request) Successful in 39s
/ pr-review (pull_request) Successful in 1m24s
/ checks (pull_request) Successful in 17m22s
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ container (pull_request) Successful in 1m47s
a49c7862d6
forgejo-actions left a comment

WuMing

Found 4 issue(s). See inline comments below.

## WuMing Found **4** issue(s). See inline comments below.
@ -0,0 +25,4 @@
NOT NULL user_id (SQLite cannot ADD COLUMN ... NOT NULL), so every
pre-ownership aggregate becomes owned by user 1.
"""
op.execute("INSERT INTO users (username) SELECT 'default' WHERE NOT EXISTS (SELECT 1 FROM users WHERE id = 1)")

security [LOW]

A05/A07: The migration seeds a guessable 'default' account with no password/credentials and no explicit disabled state. If any authentication path treats a NULL password_hash as acceptable, or if the setup flow that runs while a passwordless user exists is reachable unauthenticated, this becomes a default-credential entry point. Seed the account disabled or with a random unusable hash, and require explicit operator provisioning of credentials.

**security** [LOW] A05/A07: The migration seeds a guessable 'default' account with no password/credentials and no explicit disabled state. If any authentication path treats a NULL password_hash as acceptable, or if the setup flow that runs while a passwordless user exists is reachable unauthenticated, this becomes a default-credential entry point. Seed the account disabled or with a random unusable hash, and require explicit operator provisioning of credentials. <!-- wuming:sha256:e9cb14b2c559cf7ddf6b8f78232ca72bfa9af2e2b3cbc3da2f5b396de8cb41e5 -->
marvin8 marked this conversation as resolved
docs/cli.md Outdated
@ -64,10 +69,28 @@ yunjin add-feed URL --title "Feed Title" [--description "..."] [--interval MINUT
| `--title` | *required* | Display name for the feed. |

docs [LOW]

The prose at lines 59–61 states that for an existing feed a later subscriber's --title is ignored on reuse, yet this table still marks --title as required. Readers subscribing to an already-known URL will be unsure whether they must supply (a useless) title. Consider softening the requirement note, e.g. "--title | required | Display name for the feed. Ignored when the feed already exists (the first subscriber's title is kept)."

**docs** [LOW] The prose at lines 59–61 states that for an existing feed a later subscriber's `--title` is ignored on reuse, yet this table still marks `--title` as *required*. Readers subscribing to an already-known URL will be unsure whether they must supply (a useless) title. Consider softening the requirement note, e.g. "`--title` | *required* | Display name for the feed. Ignored when the feed already exists (the first subscriber's title is kept)." <!-- wuming:sha256:f62b158a7c899add45aa0f1787fb9b8eb5f9d638203be62a8816285308f9cd3d -->
@ -52,2 +52,2 @@
"ruff~=0.16.8",
"ty~=0.0.83",
"ruff~=0.16.10",
"ty~=0.0.84",

config [MEDIUM]

ty is bumped to ~=0.0.84 here and v0.0.84 in .pre-commit-config.yaml, but pylock.toml has no corresponding hunk for ty (every other tool bumped in this PR — ruff 0.16.10, uv 0.12.22, prek 0.5.4 — is updated in all three places). The lock file is therefore stale and does not satisfy the new constraint; regenerate it (uv lock / the uv-lock+uv-export pre-commit hooks) and commit the updated ty entry so the lock, pre-commit rev and project requirement agree.

**config** [MEDIUM] `ty` is bumped to `~=0.0.84` here and `v0.0.84` in `.pre-commit-config.yaml`, but `pylock.toml` has no corresponding hunk for `ty` (every other tool bumped in this PR — ruff 0.16.10, uv 0.12.22, prek 0.5.4 — is updated in all three places). The lock file is therefore stale and does not satisfy the new constraint; regenerate it (`uv lock` / the `uv-lock`+`uv-export` pre-commit hooks) and commit the updated `ty` entry so the lock, pre-commit rev and project requirement agree. <!-- wuming:sha256:d877311561cc3b4fd619b3367a94e3135a64e1a5352e7949d29ba57fce178ae9 -->
marvin8 marked this conversation as resolved
@ -95,1 +95,4 @@
check-licenses = true
[[tool.zaojun.license-ignore]]
package = "waitress"

config [LOW]

A new [[tool.zaojun.license-ignore]] entry suppresses the check-licenses = true gate for a runtime dependency (waitress~=3.0.2) that this PR does not otherwise touch. Excluding a package from licence compliance checking is a policy decision, not a version-bump side effect — confirm the licence really is ZPL-2.1 and that the upstream metadata gap cannot be fixed, and consider record-keeping elsewhere (e.g. an allow-list reviewed by the operator) so the ignore is not silently inherited by future waitress bumps.

**config** [LOW] A new `[[tool.zaojun.license-ignore]]` entry suppresses the `check-licenses = true` gate for a runtime dependency (`waitress~=3.0.2`) that this PR does not otherwise touch. Excluding a package from licence compliance checking is a policy decision, not a version-bump side effect — confirm the licence really is ZPL-2.1 and that the upstream metadata gap cannot be fixed, and consider record-keeping elsewhere (e.g. an allow-list reviewed by the operator) so the ignore is not silently inherited by future `waitress` bumps. <!-- wuming:sha256:bb4ad7bbc8c2a300f403e1b9e03a1aad1172caece93f2e07b26d6f13507e9f79 -->
marvin8 marked this conversation as resolved
📝 Mark reuse-ignored add-feed options in the CLI table
All checks were successful
/ gitleaks (pull_request) Successful in 38s
/ pr-review (pull_request) Successful in 1m22s
/ checks (pull_request) Successful in 17m14s
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ container (pull_request) Successful in 1m42s
f74f9f2f9f
Author
Collaborator

docs/cli.md

docs [LOW] — see full comment at the link.

✅ Fixed in commit f74f9f2 — the table rows for --title, --description, and --interval now each carry "Ignored on reuse.", matching the prose above the table.

[`docs/cli.md`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4205) > **docs** [LOW] — see full comment at the link. ✅ Fixed in commit `f74f9f2` — the table rows for `--title`, `--description`, and `--interval` now each carry "Ignored on reuse.", matching the prose above the table.
Author
Collaborator

pyproject.toml

config [MEDIUM] — see full comment at the link.

🔴 Not correct — pylock.toml does pin ty 0.0.84: name = "ty" at pylock.toml:1453 with the 0.0.84 sdist and wheel set at :1456-1460, matching pyproject.toml's ty~=0.0.84 and .pre-commit-config.yaml's v0.0.84. The lock, pre-commit rev and requirement already agree; no regeneration needed.

[`pyproject.toml`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4203) > **config** [MEDIUM] — see full comment at the link. 🔴 Not correct — `pylock.toml` does pin `ty` 0.0.84: `name = "ty"` at pylock.toml:1453 with the 0.0.84 sdist and wheel set at :1456-1460, matching `pyproject.toml`'s `ty~=0.0.84` and `.pre-commit-config.yaml`'s `v0.0.84`. The lock, pre-commit rev and requirement already agree; no regeneration needed.
Author
Collaborator

pyproject.toml

config [LOW] — see full comment at the link.

🔴 The rationale was reworded to exactly this standard in commit a49c786 (which this review ran on): it now records the SPDX identifier (ZPL-2.1), the licence character (permissive, OSI-approved, BSD-style notice obligations), the upstream metadata gap as the reason zaojun reports UNKNOWN, and explicit operator acceptance. Whether the upstream metadata gap gets fixed and any further record-keeping are the repository owner's policy decisions.

[`pyproject.toml`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4204) > **config** [LOW] — see full comment at the link. 🔴 The rationale was reworded to exactly this standard in commit `a49c786` (which this review ran on): it now records the SPDX identifier (ZPL-2.1), the licence character (permissive, OSI-approved, BSD-style notice obligations), the upstream metadata gap as the reason zaojun reports UNKNOWN, and explicit operator acceptance. Whether the upstream metadata gap gets fixed and any further record-keeping are the repository owner's policy decisions.
Author
Collaborator

alembic/versions/20261003_feed_subscriptions_and_aggregate_owner.py

security [LOW] — see full comment at the link.

🔴 Declined — this is the documented bootstrap design, not a default-credential hole. Authentication never treats a NULL password_hash as acceptable (login explicitly rejects passwordless users), and while any user lacks a credential the whole app redirects to /setup, whose handler re-verifies the gate condition against the database on every request; once any user is credentialed, /setup is permanently disabled. Seeding a random unusable hash would actively break this: the setup gate opens only while no user has a password, so it would lock operators out of first-run bootstrap on upgraded deployments. Operator provisioning exists via yunjin set-password (and the setup page itself). Account lifecycle hardening is deliberately Effort 3.

[`alembic/versions/20261003_feed_subscriptions_and_aggregate_owner.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4206) > **security** [LOW] — see full comment at the link. 🔴 Declined — this is the documented bootstrap design, not a default-credential hole. Authentication never treats a NULL `password_hash` as acceptable (login explicitly rejects passwordless users), and while any user lacks a credential the whole app redirects to `/setup`, whose handler re-verifies the gate condition against the database on every request; once any user is credentialed, `/setup` is permanently disabled. Seeding a random unusable hash would actively break this: the setup gate opens only while no user has a password, so it would lock operators out of first-run bootstrap on upgraded deployments. Operator provisioning exists via `yunjin set-password` (and the setup page itself). Account lifecycle hardening is deliberately Effort 3.
forgejo-actions left a comment

WuMing

Found 4 issue(s). See inline comments below.

## WuMing Found **4** issue(s). See inline comments below.
docs/reader.md Outdated
@ -47,0 +46,4 @@
- Add a feed by URL or by site address (Yunjin discovers candidate feeds).
Subscribing to a feed another account already has reuses the same feed —
each account keeps its own subscription and aggregates.
- **Unsubscribe** from a feed. Unsubscribing removes the feed and its

docs [LOW]

"Unsubscribing removes the feed and its aggregates from your account" reads as though the shared feed row itself is deleted, which contradicts the shared-feed model stated two lines above (feeds are shared across accounts; only the subscription and aggregates are per account). Reword to say that unsubscribing removes your subscription and your aggregates, and that the shared feed row is deleted only when the last subscriber leaves.

**docs** [LOW] "Unsubscribing removes the feed and its aggregates from your account" reads as though the shared feed row itself is deleted, which contradicts the shared-feed model stated two lines above (feeds are shared across accounts; only the subscription and aggregates are per account). Reword to say that unsubscribing removes your subscription and your aggregates, and that the shared feed row is deleted only when the last subscriber leaves. <!-- wuming:sha256:710521e9d004332e04dee4514e5e5cfa2545b28bfb439f9569babcfe3f9c4f3e -->
@ -47,0 +48,4 @@
each account keeps its own subscription and aggregates.
- **Unsubscribe** from a feed. Unsubscribing removes the feed and its
aggregates from your account; when you are the last subscriber, the feed
and its articles are removed for everyone.

docs [LOW]

"the feed and its articles are removed for everyone" is misleading: being "the last subscriber" means no other account is subscribed, so "for everyone" implies an impact on other users that cannot exist. Say instead that the shared feed and its stored articles are deleted (or "removed entirely").

**docs** [LOW] "the feed and its articles are removed for everyone" is misleading: being "the last subscriber" means no other account is subscribed, so "for everyone" implies an impact on other users that cannot exist. Say instead that the shared feed and its stored articles are deleted (or "removed entirely"). <!-- wuming:sha256:41cba3b974c21722c9ae543cf20feb9d461f1c41d4c95cc0e8028624cd6b00ab -->
@ -267,6 +267,36 @@ def get_all_articles(conn: sqlite3.Connection, limit: int | None = None, offset:
return [_article_from_row(row) for row in rows]
def get_articles_for_feeds(conn: sqlite3.Connection, feed_ids: list[int]) -> list[Article]:

tests [LOW]

New function get_articles_for_feeds has no direct test: no test in the diff references it, it is only exercised indirectly through the aggregator. In particular the empty-list guard on line 281 is unreachable from its only caller (llm_aggregator._new_articles_for_user returns early when there are no subscriptions), so that branch is never covered. Add focused tests for the empty feed_ids case and the multi-feed IN-clause filtering.

**tests** [LOW] New function `get_articles_for_feeds` has no direct test: no test in the diff references it, it is only exercised indirectly through the aggregator. In particular the empty-list guard on line 281 is unreachable from its only caller (llm_aggregator._new_articles_for_user returns early when there are no subscriptions), so that branch is never covered. Add focused tests for the empty `feed_ids` case and the multi-feed IN-clause filtering. <!-- wuming:sha256:3bfc986d69038a2541ecd6f21d8120215c5d51610f079ba5fd1e8160fbba9a12 -->
marvin8 marked this conversation as resolved
@ -109,2 +112,3 @@
user = users.get_user_by_username(db_conn, "test_user")
assert user is not None
if user is None:
user = users.create_user(db_conn, "test_user")

tests [MEDIUM]

This helper used to validate fixture state with assert user is not None; it now silently creates test_user in the shared db_conn fixture when missing. That both weakens the guard (a misconfigured fixture no longer fails) and mutates shared fixture state from a helper called by many tests without any teardown/restore. Restore the assertion (the sibling _other_user_id helper at line 2125 still does exactly this), or create the user in a dedicated fixture so setup failures surface.

**tests** [MEDIUM] This helper used to validate fixture state with `assert user is not None`; it now silently creates `test_user` in the shared `db_conn` fixture when missing. That both weakens the guard (a misconfigured fixture no longer fails) and mutates shared fixture state from a helper called by many tests without any teardown/restore. Restore the assertion (the sibling `_other_user_id` helper at line 2125 still does exactly this), or create the user in a dedicated fixture so setup failures surface. <!-- wuming:sha256:589c40e80bf1f3bc040dbb01b7cc2544466818603e21617b1343716849cd3d2c -->
Author
Collaborator

docs/reader.md

docs [LOW]…

✅ Fixed in commit from this wave — the sentence now reads: "Unsubscribing removes your subscription and your aggregates that contained that feed's articles. The shared feed row itself is deleted only when the last subscriber leaves — along with its stored articles."

[`docs/reader.md`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4213) > **docs** [LOW]… ✅ Fixed in commit from this wave — the sentence now reads: "Unsubscribing removes your subscription and your aggregates that contained that feed's articles. The shared feed row itself is deleted only when the last subscriber leaves — along with its stored articles."
✅ Cover get_articles_for_feeds and clarify unsubscribe docs
All checks were successful
/ gitleaks (pull_request) Successful in 39s
/ pr-review (pull_request) Successful in 1m30s
/ checks (pull_request) Successful in 17m34s
/ publish-container (pull_request) Has been skipped
/ deploy-docs (pull_request) Has been skipped
/ container (pull_request) Successful in 1m47s
7d3b6c2b56
Author
Collaborator

docs/reader.md

docs [LOW]…

✅ Fixed in the same commit — "removed for everyone" is gone; it now says the shared feed row is deleted only when the last subscriber leaves, along with its stored articles.

[`docs/reader.md`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4214) > **docs** [LOW]… ✅ Fixed in the same commit — "removed for everyone" is gone; it now says the shared feed row is deleted only when the last subscriber leaves, along with its stored articles.
Author
Collaborator

src/yunjin/db/articles.py

tests [LOW]…

✅ Fixed — get_articles_for_feeds now has direct tests: empty feed_ids returns [] (covering the previously unreachable guard), and multi-feed IN-clause filtering plus newest-first ordering are asserted. See tests/test_db.py::TestArticles::test_get_articles_for_feeds_*.

[`src/yunjin/db/articles.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4216) > **tests** [LOW]… ✅ Fixed — `get_articles_for_feeds` now has direct tests: empty `feed_ids` returns `[]` (covering the previously unreachable guard), and multi-feed IN-clause filtering plus newest-first ordering are asserted. See `tests/test_db.py::TestArticles::test_get_articles_for_feeds_*`.
Author
Collaborator

tests/test_web.py

tests [MEDIUM]…

🔴 Declined — the get-or-create is deliberate and fixes a real bug from this PR's own Task 1: test_build_aggregate_row consumes sample_aggregate without auth_client, so test_user legitimately did not exist yet and the hard assert failed on fixture ordering. Restoring the assert would re-break that path; the docstring documents the fallback. The alternative (a dedicated test_user fixture consumed by every caller) is a wider refactor we're happy to consider post-merge, but it is not a correctness gap now.

[`tests/test_web.py`](https://forge.marvin8.zone/marvin8/yunjin/pulls/66#issuecomment-4215) > **tests** [MEDIUM]… 🔴 Declined — the get-or-create is deliberate and fixes a real bug from this PR's own Task 1: `test_build_aggregate_row` consumes `sample_aggregate` without `auth_client`, so `test_user` legitimately did not exist yet and the hard assert failed on fixture ordering. Restoring the assert would re-break that path; the docstring documents the fallback. The alternative (a dedicated `test_user` fixture consumed by every caller) is a wider refactor we're happy to consider post-merge, but it is not a correctness gap now.
marvin8 approved these changes 2026-10-04 02:12:46 +00:00
marvin8 manually merged commit f80a44a309 into feat/multiuser 2026-10-04 02:30:46 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No assignees
3 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Reference
marvin8/yunjin!66
No description provided.