37 commits from dev since #250 (2026-09-02). The operator asked for this merge so the work can be tested. CI passed on the head commit e3c516d: ci run 6704 (616 unit, 1298 integration, lint, frontend) and build run 6703. The extension checks passed on c2f9e9c (run 6702), and nothing extension-side has changed since.
Milestone 387: why posts are missing, and what you subscribe to
Tier-gated posts are reported now (A1–A3). A paywalled creator no longer looks the same as a silent one, and each source shows whether FC has access.
The front door (B1, B3, B4) answers "what arrived?" and "is ingestion working?". On an empty install it is the first screen, and it offers to find the creators you already subscribe to (C6).
The membership roster (C1–C5, C7, D1):
A daily sweep reads what the account pays for on Patreon and SubscribeStar.
It compares that against the creators FC follows.
It explains why a source's posts are invisible, without deciding that they are.
Milestone 388: Discord
Grouped posts (E2, E3). FC writes the post that Discord never did: a burst of images becomes one post, a later drop can join it, and the grouping settings are tunable.
Creator suggestions (E4). FC offers the creator you already track as the one you subscribe to.
Announcement links (E5). A Patreon teaser is linked to the Discord drop it announced, shown on the card and in a review queue.
Milestone 406, phase 1: pixiv retired
pixiv is no longer a registered platform, and the extension no longer asks for access to it.
A source on any unregistered platform is refused before it reaches a downloader.
A running extension deletes any pixiv token it still holds.
The docs no longer claim pixiv, or "anything gallery-dl supports".
Fixes
System health is a Settings tab.
Editing a source no longer wipes its campaign id and backfill position.
The provenance panel shows local copies of images instead of loading them from the platform CDN (#3965).
The three relative-time formatters are now one.
New brand mark.
Deploy notes: migrations 0091 → 0097 run on startup
0091–0096 only add things: new tables (platform_membership, membership_sync, post_association, artist_membership_suggestion) and new columns on post, ml_settings and import_settings. Every drop is in a downgrade.
0097 changes data. It sets enabled = false on any still-enabled pixiv or deviantart source and clears its error state. No row, post or image is deleted. Its downgrade is a deliberate no-op.
No compose or stack-file change is needed. The .env.example / docker-compose.yml diff is two comment lines that drop the word Pixiv.
37 commits from `dev` since #250 (2026-09-02). The operator asked for this merge so the work can be tested. CI passed on the head commit `e3c516d`: ci run 6704 (616 unit, 1298 integration, lint, frontend) and build run 6703. The extension checks passed on `c2f9e9c` (run 6702), and nothing extension-side has changed since.
## Milestone 387: why posts are missing, and what you subscribe to
- **Tier-gated posts are reported now** (A1–A3). A paywalled creator no longer looks the same as a silent one, and each source shows whether FC has access.
- **The front door** (B1, B3, B4) answers "what arrived?" and "is ingestion working?". On an empty install it is the first screen, and it offers to find the creators you already subscribe to (C6).
- **The membership roster** (C1–C5, C7, D1):
- A daily sweep reads what the account pays for on **Patreon and SubscribeStar**.
- It compares that against the creators FC follows.
- It explains why a source's posts are invisible, without deciding that they are.
## Milestone 388: Discord
- **Grouped posts** (E2, E3). FC writes the post that Discord never did: a burst of images becomes one post, a later drop can join it, and the grouping settings are tunable.
- **Creator suggestions** (E4). FC offers the creator you already track as the one you subscribe to.
- **Announcement links** (E5). A Patreon teaser is linked to the Discord drop it announced, shown on the card and in a review queue.
## Milestone 406, phase 1: pixiv retired
- pixiv is no longer a registered platform, and the extension no longer asks for access to it.
- A source on any unregistered platform is refused before it reaches a downloader.
- A running extension deletes any pixiv token it still holds.
- The docs no longer claim pixiv, or "anything gallery-dl supports".
## Fixes
- System health is a Settings tab.
- Editing a source no longer wipes its campaign id and backfill position.
- The provenance panel shows local copies of images instead of loading them from the platform CDN (#3965).
- The three relative-time formatters are now one.
- New brand mark.
## Deploy notes: migrations 0091 → 0097 run on startup
- **0091–0096 only add things**: new tables (`platform_membership`, `membership_sync`, `post_association`, `artist_membership_suggestion`) and new columns on `post`, `ml_settings` and `import_settings`. Every drop is in a downgrade.
- **0097 changes data.** It sets `enabled = false` on any still-enabled pixiv or deviantart source and clears its error state. No row, post or image is deleted. Its downgrade is a deliberate no-op.
- No compose or stack-file change is needed. The `.env.example` / `docker-compose.yml` diff is two comment lines that drop the word Pixiv.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
The surface shipped at /system with no nav entry, reachable only by
clicking the health dot beside the brand — a target you have to already
suspect something is wrong to go looking for. Operator-flagged: it needs
a path someone can walk to.
Settings is where you go to ask the instance about itself, so the view
becomes a tab there, beside Activity — Activity answers "what is the
queue doing", System answers "is anything left to do it".
- SystemView.vue moves to components/settings/SystemHealthTab.vue; the
content is unchanged apart from shedding its own container and h1.
- SettingsView adopts useTabQuery (the composable Browse and
Subscriptions already use) so a tab can be linked TO. The health dot
now points at ?tab=system, and /system redirects there so the previous
build's link and any bookmark still land.
- The tab drops its own 10s poll. v-window keeps a visited item mounted
rather than destroyed, so that timer would have gone on firing behind
Maintenance — and TopNav already polls the same store every 15s for
the dot.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TTjbZZ6JirCMSaJzQV1RhA
`make_run_stats` has always declared `tier_gated_count`, and
DownloadDetailModal has always rendered it. gallery-dl populated it;
`ingest_core._result` did not — it built run_stats with six keys and let
the seventh default to 0, while the very same walk counted gated posts
into `gated_skipped` and spent the number on a log line.
So on Patreon, SubscribeStar and pixiv — the three platforms we now own —
the Downloads modal read "Tier-gated: 0" for a walk that skipped N
paywalled posts. A creator we've lost access to was indistinguishable
from a creator who stopped posting. Migrating Patreon off gallery-dl is
what dropped the signal.
Pass the count through, and tick it in the live-progress payload too, so
a long backfill on an inaccessible creator explains itself while it runs
rather than only at finalization. ActiveDownloadsPanel renders it only
when non-zero, coloured 'info' to match the severity FailingSourcesCard
already assigns tier_limited — this is not a failure.
Tests assert the run_stats key the UI actually reads rather than the
ingester's internal counter, so the guard tracks the property and not a
name. Falsification is structural: `make_run_stats` defaults the key to
0, so both assertions fail against the pre-fix call.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
A2 of milestone 387. A1 made the gated-post count true; this makes it
mean something.
A native walk that reached the bottom returned `error_type=None`
whether the creator had posted nothing or every post sat behind a tier
we don't hold. `source.error_type` stayed NULL and the source read
healthy and quiet. gallery-dl has classified this as TIER_LIMITED since
the paywall-as-"needs attention" complaint; the native path never did.
Two things had to move that the plan didn't foresee, both found by
reading the consumers rather than by testing afterwards:
The backfill lifecycle's completion test required `error_type is None`.
Returning TIER_LIMITED naively would have dropped a fully-paywalled
backfill into the not-finished branch — zero downloads means no
progress, two strikes marks it "stalled" — so the creator we can see
least would become the one we re-walk most. `walk_completed` now admits
informational classes.
`_update_source_health` only stamps `error_type` on status "error" and
CLEARS it on "ok". Since TIER_LIMITED is a success, the chip was wiped
by the very run that produced it — which is why FailingSourcesCard's
`tier_limited` palette entry has never been reachable. An "ok" run now
keeps an informational class while failures stay 0 and last_error stays
clear: the run did not fail and must not earn a backoff.
Deviation from the plan, deliberate: the filed step said classify only
when `downloaded == 0`. gallery-dl doesn't condition on that, and
diverging the two backends over the same concept is what rule 169
forbids — so the native path mirrors it. "There is content here you
aren't paying for" is equally true in a week we also got the cheap
posts. Pinned by a test, since the stricter rule looks more correct.
The predicate, the wording and the completion test are defined once in
gallery_dl.py and spread into both backends (snippet 3087), rather than
re-derived per half — which is exactly how they drifted apart before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
A3 of milestone 387, completing phase A. A1 made the count true, A2
made it a durable state; this makes it something the operator can see
without going looking.
Turned out smaller than filed, because A2 revealed why the existing
`tier_limited` palette entry in FailingSourcesCard had never rendered:
the chip was being cleared by the same successful run that produced it.
The colour was already chosen.
Where it surfaces:
- SourceHealthDot gains a `no-access` grade. Deliberately its own grade
rather than folded into healthy (which hides it) or warning (which
sends the operator hunting for a break that isn't there). A source
with real failures still grades as failing whether or not it is also
gated.
- SourceRow gets an info-coloured lock chip in the status cell, which
was empty for these sources — they have zero failures. Placed ahead
of the backfill states: "we can't see this creator" is the more
useful thing to say than which walk phase it is in, and unlike those
it does not resolve on its own.
- A "No access" status filter, deliberately separate from "Has errors".
Without it a gated source is invisible in a long list, because it
correctly stays out of the failing rollup.
Left OUT of NeedsAttentionCard on purpose. That card's only affordance
is Retry, and you cannot retry your way into a subscription tier —
issue 1285 already gives the real escape hatch, since disabling a
source clears its state. Nothing structural needed changing: the card
is fed by consecutive_failures > 0, which a tier-limited source never
has.
The count lives on the download event, not the source, so `list()`
joins it in with one DISTINCT ON query — selecting the run_stats
sub-object rather than whole metadata blobs, which carry up to 500KB of
truncated stdout each. Scoped to tier-gated rows only, so a healthy
library issues no extra query at all. Absent stays None rather than 0,
and both UI surfaces phrase the state without a number when it is
missing instead of printing a fabricated zero.
Also covers A1's live gated count, which shipped untested, and extends
the mount helper with slot stubs: SourceHealthDot puts the dot in a
NAMED slot, and unresolved Vuetify components render default slots
only — so those assertions would have found an empty wrapper and
passed vacuously.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
Replaces the placeholder folder glyph with the new logo: a white-gloved
hand presenting a framed work, which says what the app is for far
better than the old mark did.
Two assets, because one cannot serve both jobs. Measured, not assumed:
rendered at 22px the full logo is unreadable mush, so favicon.svg stays
a separate, much simpler mark.
logo.svg — traced from the source raster with potrace, then repainted
from the design tokens. Segmentation notes, since this is the part that
is easy to get wrong on a re-do: hue does NOT separate the glove from
the frame's highlights (both sit near 38 degrees) and neither does
saturation alone. The split is a connected-component fill seeded inside
the cuff, dilated first so it can cross the dark outline strokes that
cut the fingertips off from the palm.
The source plate was a warm brown (#1B1105), not the app's cool
obsidian (#14171A) — side by side it read as a logo sitting on its own
warmer card. It is dropped entirely: the mark is transparent and the
frame interior shows whatever surface hosts it.
The source gold was #AA7E39, which is within a couple of points of
accent.curator #A87338 — so the mark now shares one colour with
nav-active text and the wordmark rather than nearly sharing it. The
glove goes to text.parchment for the same reason.
favicon.svg — hand-drawn rather than traced. At 16px a traced mark
carries hundreds of wobble nodes that read as fuzz and can never be
tidied. Ring + frame + star merged into a blob at that size, so it
keeps two elements: the frame and the star. Frame over a plain ring
because it carries the meaning, and it is the full logo's own
centrepiece; the glove, cufflink, finials and sparkle rays are
deliberately absent rather than drawn and lost.
The favicon keeps its obsidian plate so the tab icon is self-contained
against any browser chrome; on the nav that plate is invisible because
it matches --fc-chrome-rgb exactly. logo.svg has no plate at all.
Both files carry a comment explaining why they are shaped this way, so
the next edit does not undo the reasoning. The old favicon is one
revert away in history.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
Moves FRONT_DOOR from /showcase to /latest. Showcase is a random
TABLESAMPLE — lean-back, and it can never tell you anything is wrong.
The feed is the only view where a failing source surfaces on its own,
as a creator who has gone quiet. The per-artist "new since last visit"
badges (#597) were a workaround for this view not being the door.
Showcase is demoted to a nav entry, not removed. Nothing is being
replaced, so rule 22's delete-the-legacy-path does not apply.
Deviation from the filed plan, deliberate: the step said write a new
LatestView.vue. Rejected — PostsView is ALREADY a self-contained feed
(own container, own store, infinite scroll, filters, deep-link
anchoring, empty state), and Browse only ever wrapped it in a tab
strip. A new view would have duplicated 231 working lines to gain
nothing. Mounting PostsView directly at its own route IS the whole
difference the promotion was after: a door you arrive at, not a hub
you navigate out of. Rule 28.
Backend untouched, as scoped — PostFeedService.scroll already does
cursor-paginated newest-first.
Two things this shook loose:
PostsView's deep-link "All posts" button was a hard `{ name: 'posts' }`,
which redirects into Browse. Correct while the view only ever rendered
inside Browse's tab; from the front door it would have yanked the
operator sideways into a different surface. Now returns to the current
route minus post_id, so Browse keeps its tab and any active scope.
The README claimed "A Showcase front page". That block is the SOURCE
the release notes quote (scripts/release_notes.py product_overview),
not a generated copy, so it is fixed here — a document contradicting
the code is the characteristic defect of the public-surface area.
No stickyChrome on the route: unlike Browse/Gallery/Settings this view
has no sticky sub-header for the nav to butt against.
The router spec pinned FRONT_DOOR to /showcase and now pins /latest,
plus that Showcase stayed reachable and in the nav — the demotion is
asserted, not assumed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
The step phase A was building toward. A1 made the gated count true, A2
made it a durable state, A3 made it visible in Subscriptions — but
Subscriptions is where you go once you already suspect something. This
is the line that reaches someone who wasn't looking.
A thin grey strip above the feed, front door only: last check, sources
failing, sources you can't see. Only the actionable items take a
colour, and nothing renders at zero — a permanent "0 failing" trains
you to skip the line, which would hide the real number when it appears.
Two predicates, defined once. The ribbon counts and the surfaces it
links to have to agree on what "failing" and "no access" MEAN, or the
ribbon says 3 and the card shows 4. They live in db_helpers, which
exists for exactly this reason (its docstring: divergent copies are how
the race bugs crept in). Not in source_service, because
scheduler_service needs them too and source_service already imports
scheduler_service — the other direction is a cycle.
Counting deliberately spans all ENABLED sources rather than the
auto_check subset scheduler_status already walks: a source erroring on
a manual-only artist is still erroring. Disabled sources count for
nothing, which is what makes issue 1285 the real escape hatch for a sub
you stopped paying for.
Extends the existing schedule-status endpoint rather than adding a
parallel aggregate — the store already fetches it. Two scalar COUNTs.
The status filter is now URL-addressable, which it had to be for the
ribbon's links to land anywhere: a count that drops you on an
unfiltered list makes the reader redo the filtering the ribbon just
did. Mirrors how artistFilter already reads from route.query.
Front-door-only via a route prop, not a route.name check, so the view
doesn't need to know what it's mounted as and the router states the
intent in one place. Inside Browse's Posts tab you're looking FOR
something and the hub is one click away.
The fetch is swallowed on mount by design (rule 164): this is an aside,
and the feed must render whether or not the status call succeeds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
B3 added failing_sources / no_access_sources to schedule-status and
broke test_schedule_status_shape and test_summary_returns_rollup_shape,
both of which assert the payload's EXACT key set. My own tests passed;
these two did not, and integration caught it.
A rule 90 miss: I grepped for the predicates I changed and not for
consumers of the response shape. The shape is the thing I actually
changed.
Both keys added to the assertions rather than loosening them to a
subset check — an exact-set assertion is what catches a key being
renamed out from under a consumer, which is precisely the value these
two tests just demonstrated.
The other readers (PipelineStatusChip, SchedulerStatusBar) pull
individual keys, so they were unaffected. SchedulerStatusBar's prop
comment documented the old shape and is corrected here; a comment that
lies about a contract is worth the same as a doc that does.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
The front door is now a feed, and a blank feed implies things should be
here in a way a blank masonry does not. On a fresh install this is the
first screen anyone sees — including someone who is not the operator,
which is what milestone 328 is making possible.
Tells the two empties apart, which is the point. "No sources yet" gets
the on-ramp; "sources configured, nothing landed yet" gets told that
the first check takes a while and pointed at Downloads. Telling someone
to add a source when they already have three and are mid-backfill reads
as the app not knowing its own state.
Needs total_sources on schedule-status to distinguish them —
deliberately not auto_sources, which counts only what is on a schedule,
so a source with auto_check off would have read as "nothing
configured". Both exact-shape assertions updated in THIS change rather
than after CI caught them, which is the lesson from B3's red push.
An absent status falls back to the on-ramp on purpose: it is merely
redundant to an established operator, whereas "see what's running"
shown to someone with nothing configured is a dead end.
A filtered miss is deliberately NOT the onboarding case — the operator
has posts, they just narrowed past them. Showing a fresh-install
on-ramp there would tell someone with a full library to go set it up.
This is where logo.svg lands, as the operator asked. It earns its place
on a first-run screen and not on a populated feed, and gives the
on-ramp something to compose around instead of prose plus two buttons.
Large: the mark stops reading below ~48px, which is why the 22px nav
slot has a different one. Pinned by test so a later tidy-up cannot
quietly shrink it to a glyph.
Also extracts mountWithStore into the shared test support module.
Writing the second spec created exactly the copy-paste that open issue
3109 tracks for the backend row factories, so it is consolidated now
rather than at copy three, and recorded as snippet 3829 with the two
traps it does NOT solve — named slots rendering nothing, and
components that fetch on mount.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
FC knows which creators it was TOLD to follow and nothing about which
ones the operator is subscribed to. Those two sets drift both ways and
neither drift is currently visible: a subscription FC doesn't track is
content the operator believes they're archiving and aren't, and a
source walked after the subscription lapsed is requests spent on a wall
reported as a creator gone quiet.
Sibling of service_seen (milestone 365) and the same insight — an
absence is only observable against a record of presence. touch_membership
reuses the recorded touch_service shape (snippet 3447): upsert rather
than read-modify-write, first_seen_at deliberately outside the update
set because it's the one field that makes a later DISAPPEARANCE
readable as a lapse rather than as a creator we never knew.
Nothing populates it yet, and that's the intended intermediate state.
The sweep (C3) needs a client seam (C2) that needs Patreon's real
response characterised from a captured sample (C0), which needs the
operator's browser session. The table's SHAPE doesn't wait on that,
because it's deliberately free-form exactly where C0's findings would
otherwise dictate a column.
status is an unconstrained String holding the PLATFORM's own word, not
a normalised FC value. Rule 36 considered and declined, same reasoning
service_seen.kind records: the vocabulary isn't ours to invent, and
picking a lowest-common-denominator enum before any platform has been
characterised would bake a guess into the schema. The service owns the
whitelist and the mapping; the column owns the evidence.
MEMBERSHIP_STATUS ships EMPTY, guarded by a test that fails if anyone
adds an entry — every one must come from a characterised response, not
from API docs. That's rule 130 at the one place it's easiest to break,
and the failure message says so.
has_paid_access returns None, never False, for a word it hasn't been
taught. The difference is load-bearing: False means the operator lost
access, which C4 turns into an offer to disable the source, so
asserting it from an unrecognised word would tell them to cancel a
subscription they're still paying for.
Retention decided here rather than deferred (rule 89): a membership
that stops appearing is aged out on time, never deleted on absence —
deleting would destroy the signal at the moment it became interesting.
Rule 90 check, done on the right thing this time: the per-test TRUNCATE
teardown derives its table list from Base.metadata.sorted_tables, so
the new table is picked up automatically; test_models asserts a subset,
so it doesn't break. 0091 follows 0090 on the collapsed baseline.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
Discord is a delivery channel, not a publisher. One message is not one post,
and today every message lands as its own `post` row, so chat lines compete
with authored work for the same surface. Rather than demote them into a
second-class feed, FC now writes the post itself: one row per DROP, its
images the drop's images, its body the messages' text in arrival order.
Synthesising a `Post` (rather than inventing a parallel entity) is the whole
point — the result is post-shaped by construction, so feed, provenance,
translation, attachments and series keep working on it unchanged.
The predicate is three axes ANDed, and the time one does the real work:
same source AND cosine distance <= threshold AND no gap > window
Similarity alone over-groups, and that is the failure that would make this
useless: any two pieces of the same character by the same artist sit close in
SigLIP space, so a cosine-only rule collapses a month of one character into a
single "post". Two details inside the predicate are load-bearing —
* distance is measured to the group's SEED, never to the previous member,
because chaining lets a group DRIFT: twenty small steps walk from one piece
to a completely different one, each hop individually within threshold;
* the window is measured between CONSECUTIVE messages, not from the first, so
an artist trickling variants out over an evening stays one drop.
Why a post-import sweep and not part of ingest. The obvious alternative was to
migrate Discord to the native post-first ingester (#1266) and group at capture
time. That cannot work: the grouping signal is `siglip_embedding`, which is
produced asynchronously AFTER import (tasks/ml.py, the GPU backfill), so at
capture time there is nothing to group on. Grouping is necessarily something
that happens once the vectors catch up — hence a re-runnable sweep that skips
what it cannot yet place, and an hourly (not daily) cadence.
The honesty rule, enforced in the schema. `post.synthesized_by` names the
grouper; `synthesis_details` records the members, the count, and the
thresholds AS THEY WERE (they are operator-tunable, so without that "why did
it group these" is unanswerable a month later). Member posts are absorbed, not
destroyed — they remain the images' true origin and the audit trail — and
`absorbed_by_post_id` is ON DELETE SET NULL, so deleting a synthetic post
releases its members back into the feed in one DELETE with no repair step.
`post_title` stays NULL deliberately: a synthesised title is the one place
this could put words in a creator's mouth.
Two guards the first draft would have failed:
* the per-run cap took the lowest post IDs, not the oldest posts — DISTINCT ON
forces its own ORDER BY, so the sort now happens outside the subquery;
* a cap landing mid-drop would have published a truncated group claiming to be
a whole drop, so the last group is left for the next run.
And one vacuous test caught before it shipped: the support vector perturbed a
single component of an all-ones vector, moving it ~1e-6, so every distance
assertion passed regardless of what the predicate did. `_vec` now builds a
unit vector at a stated angle, where distance is exactly 1 - cos(delta) —
rule 167, a guard has to be able to fail.
UI (rule 27) follows in the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
Rule 27 — E2's other half. The backend can author posts; this is what makes
that visible and adjustable.
**The honesty marker.** A chip on every synthetic post's card: "grouped by
FabledCurator", titled with what it was built from ("Grouped from 4 Discord
messages"). This chip is the only thing standing between "FC assembled this"
and the card reading as something the artist authored, so it keys off nothing
but the flag, and it states the member count rather than just disclosing that
grouping happened — a claim you can check beats a claim you're asked to trust.
A synthetic post has no title on purpose (inventing one is the one place this
feature could put words in a creator's mouth), and the untitled fallback would
otherwise have printed the internal key: "Post fc-drop:99887766". It now names
the post for what it is. There's a test for that specifically.
**The tuning card.** Ingestion & filters gets a Discord-drop-grouping tile:
the switch, the distance cut and the drop window, each with the sentence that
tells the operator which one to reach for. The window's copy says outright
that it is the setting doing most of the work — without it, everything an
artist ever drew of one character collapses into a single post.
Both directions are pinned in postCard.spec.js, including the one that
actually matters: an ordinary post is never marked. Also covered — a post dict
composed before these fields existed degrades to unmarked rather than throwing
on `synthesis.message_count`.
Also fixes the ruff UP017 that failed the lint job on 73eeb7a (timezone.utc →
datetime.UTC, the convention everywhere else in this repo). The integration
suite on that SHA was green: all 12 grouping tests passed and migration 0092
applied cleanly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
A synthetic post is no longer sealed at creation. A creator who adds two more
variants the next day extends the existing post, its body grows with the new
messages, and no rival post appears. That is what makes chat capture read as
content trickling in rather than as a stream of separate arrivals.
The sweep now runs two passes per source and the ORDER is load-bearing: offer
new messages to still-open groups BEFORE founding new ones, because whichever
runs first claims a message.
E3's three named problems, each answered rather than discovered later:
**Bridging.** A candidate near two groups joins NEITHER. Nearest-wins would
silently make an arbitrary choice between two posts the operator may already
have seen; merging them is worse still, because a merge rewrites history and
anything pointing at the absorbed post dangles. Leaving it to found its own
group is the recoverable failure. AMBIGUITY_MARGIN is a module constant and
deliberately not a setting — it is not a quality dial anyone would tune toward
a better feed, and exposing it would invite turning it to zero, which is
exactly the silent arbitrary choice it prevents.
**Re-surfacing without thrashing.** A grouping has two dates, and which one
orders the feed is a real decision, so the feed orders by neither directly.
Ordering by when the drop STARTED buries a group that grows a week later under
a week of other posts — defeating the point of keeping it open. Ordering by
every growth lets a group gaining one image a day live permanently at the top,
so chat out-competes authored posts for the front page — the opposite of "post
pacing stays front and centre". Instead `resurfaced_at` moves only when growth
clears BOTH a minimum-images bar and a cooldown, so a drip-feed updates in
place and a genuine second wave resurfaces exactly once. It is NULL on every
ordinary post, so the sort key COALESCEs through it without moving anything
that is not a grouping.
**Reopening forever.** Groups close after a quiet period — artists reuse
characters for years, and a group left open indefinitely will eventually
absorb something it shouldn't. Openness is DERIVED, not stored: a group is
open if it grew (or started) within the window. Lowering the setting closes
old groups and raising it reopens them, with nothing to repair either way; a
stored closed_at would have needed a sweep to set it and a repair path to ever
change the policy.
Rule 89 is satisfied structurally rather than by a parallel mechanism:
celery_signals writes a TaskRun for every task, which already supplies
duration, the 5-minute stalled-run recovery, and retention pruning. What this
step owed on top of that was a wall-clock limit (present) and idempotence —
re-running the joiner adds nothing, asserted directly rather than left to the
unique (image, post) constraint to catch.
Two bugs fixed in the writing, one of which my own test would have hit:
* `assign_to_group` sorted bare (distance, Post) tuples, which falls through
to comparing Posts when two distances tie — and a perfectly symmetric
bridge, the exact case the function exists for, would have raised TypeError
instead of declining to choose. Now keyed on the distance alone.
* The cursor was still built from `post_date or downloaded_at` while the
ORDER BY had gained `resurfaced_at`. Two expressions that disagree at a page
boundary don't error, they silently skip or repeat rows; both sites now go
through one `_post_sort_value`, and a test pages through one row at a time
to prove the walk matches the whole list.
Image linking is now one shared helper rather than written twice, because
creation and joining would otherwise be free to drift on exactly the detail
(which post owns the image) that makes a grouping reversible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
My own E2 test asserted `sweep`'s disabled return by exact equality, and E3
added `images_joined` to it — a rule 90 miss on a consumer I wrote an hour
earlier. Every E3 test passed; this was the only failure (1 failed, 1202
passed).
Fixed by extending the assertion, NOT by loosening it to a subset check. The
exactness is the point: a disabled sweep reports a complete zeroed shape
rather than a shorter one, so a caller can read any counter unconditionally,
and this assertion is what notices when a new counter skips that path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
The point of the milestone rather than its tail. Two of the operator's artists
post a deliberately cropped fragment on Patreon to signal that the real thing
has landed in their Discord; this proposes those pairs.
Confirm-only, following the FC-6.3 series matcher. A wrongly-asserted
association tells the operator two different pieces are one, which is strictly
worse than no link: no link leaves them where they already were, a wrong one
actively misinforms and then propagates into whatever reads it. So the
matcher's job is a SHORT list worth reading, not a long list worth trusting.
**The threshold sits above every single signal weight, and that is the
design.** Proximity is 0.55, declaration 0.45, the cut 0.60 — so neither
signal can carry a pair alone. That makes "time proximity alone is never
sufficient" an arithmetic property rather than an aspiration: on a busy day an
artist posts several times, and a matcher that could pair on proximity alone
would turn every one of those days into false pairs until the review queue got
abandoned. A guard test asserts the relationship against WEIGHTS directly, so
it survives any refactor of the scorer, and says in its own failure message not
to fix it by lowering the assertion.
**Crop-to-source matching is HELD, on the plan's instruction** — real work with
real false-positive risk, worth building only once signals 1 and 2 are shown
insufficient against the operator's actual artists. Worth stating: a naive
whole-image SigLIP similarity is NOT that signal. A cropped teaser and its full
version are precisely the pair a whole-image comparison handles worst, so
adding one as a "bonus" would mostly add noise while looking like progress.
Two premises in the plan corrected in the building:
* **E4 is not actually a prerequisite.** A Patreon Source and a Discord Source
the operator has added under one Artist already share `Post.artist_id`, and
the synthetic grouping inherits it. E4 EXTENDS this to creators FC has to
learn the association for; it is not needed to represent one FC was told.
Same-artist is then a hard filter, not a scored signal — two different
creators posting minutes apart is a coincidence, not evidence.
* **`link_extract` cannot supply the declaration signal.** It exists, but
`SUPPORTED_HOSTS` is file hosts only and `host_for()` returns None for a
Discord URL, so no ExternalLink row is ever written for one. The signal
reads the post body directly instead.
And a bug my own test would have caught: `declared_signal` stripped the HTML
before looking for an invite, but `html_to_plain` discards attributes and
these creators put the invite in an anchor's `href` — so the strongest form of
the signal was being thrown away, leaving only whatever the link text said.
The invite now matches the raw body; the bare mention still matches stripped
text, so `\bdiscord\b` is tested against prose rather than against markup.
Dismissed rows are kept, not deleted: the row is what remembers the rejection,
and re-proposing a rejected pair on every scan is the one behaviour that makes
a review queue get ignored. Both FKs CASCADE, so E3's one-DELETE reversal
cannot leave a proposal pointing at a post that no longer exists.
Only ACCEPTED links reach the post payload. A pending proposal is a question
for the review queue, not a claim to render beside the artwork.
UI (rule 27) follows in the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
Rule 27 — E5's other half. The matcher can propose; this is where the operator
decides, and where an accepted link actually shows up.
**The review queue** (Settings → Ingestion & filters). Each proposal shows the
per-signal breakdown, not just the total: "why did it suggest this" is the
question the operator actually has, and a lone percentage cannot answer it. So
a row reads "72% · timing 95% · says so 60%", and the copy states outright that
a pair always needs two reasons — which is the property that stops a busy
posting day from producing false pairs.
The empty state says so explicitly. Nothing proposed is the EXPECTED state most
of the time, and an empty queue that looks like a failure invites turning the
threshold down until it produces noise.
**On the card**, both directions, and accepted links only: the teaser gets "The
full set is in Discord", the drop gets "Announced on Patreon". A pending
proposal is a question for the review queue, never a claim to render beside the
artwork — that distinction is the whole confirm-only design, so it is asserted
in the backend (only `linked` rows reach the payload) and again here.
One detail worth the comment it carries: the link's target is `{ query: {
post_id } }` with no name or path. In vue-router that means "the current route
with these query params", so it works identically from Latest and from Browse —
and, more usefully, the card never reaches for `useRoute()`, which it has no
other reason to know about and which is not available when it is mounted in a
test without a router.
Backend CI on 235393c was green: all 13 E5 tests and migration 0094.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
build.yml and baseline.yml both explained a missing artifact step by
saying act_runner cannot run actions/upload-artifact@v4+ (and baseline.yml
said ci-requirements.md records that, which it does not). The runner is
now gitea/runner 3.x, which edits the action's GHES refusal out of its
bundle, and stock v4+ is proven working on this forge (Scribe spike #3843).
Comments only. Neither lane gains an upload step: build-web still reads
the signed XPI from the release asset, and the baseline candidate is
still printed to the log, because those remain the better channels. The
comments now say why for the right reason.
Scribe snippet #2271, milestone 395.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DwoKYuw3qJmUUYsJeNherB
C0 characterized Patreon's `/api/members` from a live capture of the
operator's own session. That capture is worth keeping — re-capturing means
re-authenticating by hand, and it is the ground truth the characterization
(Scribe note #3886) gets re-checked against when a platform's shape is
suspected to have drifted.
It cannot be committed. It carries the operator's creator list, pledge
amounts, and — inside the `card` resources the web app's include set pulls —
the account's own email address. So: a directory that is ignored wholesale
rather than by filename, so the next capture is covered by this rule instead
of needing a line somebody has to remember to add.
Two details that are the point rather than incidental:
* The ignore is written as `captures/*` plus a negation for README.md, NOT as
`captures/`. Git does not descend into an excluded DIRECTORY, so a negation
for a file inside one never takes effect — the README would have been
silently ignored along with everything else, and the convention would not
have survived a fresh clone.
* The README states plainly that SANITIZED fixtures belong in git, elsewhere
under tests/fixtures/. The raw capture exists to derive those from and to
re-check against; it is not the thing tests should load.
The ignore rule landed before the capture file did, deliberately: a payload
with an email address in it should never be sitting in the working tree
un-ignored, however briefly.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
Built on C0's real capture (Scribe note #3886), not on API docs — gallery-dl
has no membership extractor and Patreon's public v2 API is the CREATOR surface
behind OAuth, so the rule-130 reference had to be a characterized response.
**The request is deliberately minimal, and that is a privacy decision.** The
browser's own include set pulls `latest_pledge.card`, and those card resources
come back carrying the ACCOUNT HOLDER'S EMAIL in `merchant_name`; `address` is
in there too. Copying the query string wholesale is the obvious move and would
have FC fetching payment PII it has no use for and can only mishandle. We ask
for `include=campaign,reward` and nothing else, and a test asserts on the
params actually sent so nobody widens it back.
**We do not send `filter[membership_type]`.** The browser sends the six buckets
its settings page displays, which excludes lapsed memberships — and a
DISAPPEARANCE is precisely the signal the roster exists to read. Filtering here
would manufacture the event C4 acts on.
Two corrections the capture forced, both now in code:
* **The filter vocabulary is not the status vocabulary.** I had read the six
filter words off a screenshot and was about to write them into
MEMBERSHIP_STATUS as the enum. The body shows `patron_status` carrying
`former_patron` — absent from that filter — on a row the filter selected as
`free_member`. So the map is taught exactly the two OBSERVED values, and
`declined_patron` stays out despite looking obviously right: believing the
filter is the mistake that was just caught.
* **Free membership is a boolean, not a status.** `has_paid_access` gains an
`is_free_member` axis, because `active_patron` alone would report a free
follower as a paying patron and C4 would never offer to clean it up. Honest
limit, stated in the docstring: the capture has no ACTIVE free member, so it
shows the separation is possible, not that it occurs.
C1's tripwire test did its job — it was written to fail the moment anyone
populated the status map, and updating it here IS the confirmation step, done
with the capture rather than ahead of it.
`_fetch`'s retry/backoff/auth-vs-drift/Retry-After logic is extracted to a
shared `_request` so the roster rides the same path rather than growing a
second copy — two copies would drift, and the half that drifted would be the
one that only runs daily. Every error message and log line renders
byte-identically for the posts path, so the existing tests pin the refactor.
Pagination is driven by `page[offset]` against `meta.pagination.total`, never
by `links`: the response's own `links.first` is built WITHOUT the `/api/`
prefix the request uses, so following it would hit the web page. An empty page
is terminal regardless of what the total claims, so a server reporting more
rows than it hands over cannot spin the walk forever.
Drift is stricter here than on the posts path, on purpose: a missing
`meta.pagination.total` raises rather than returning a short list, because a
truncated roster reads downstream as "you cancelled those" — the worst wrong
answer this feature can give.
`current_user_id()` is marked INFERRED, not characterized: C0 captured
/api/members, not /api/current_user, so it relies only on the JSON:API envelope
this API demonstrably uses elsewhere, and raises drift rather than returning
something plausible if that is wrong.
The fixture is derived from the real capture with every piece of account data
replaced (the raw capture stays gitignored). Six members, each earning its
place: a former patron with a null pledge, an active patron with no tier, an
annual cadence, a previous_pledge whose included resource has no
`relationships` key at all, and a reward priced in CAD beside a USD charge —
the trap that makes reading `reward.amount_cents` report a number the operator
was never charged. A leak check caught a free-membership-subscription id and
six real campaign launch timestamps before any of it was staged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
A daily sweep that walks each platform's roster into `platform_membership`.
Daily because memberships change on a BILLING cycle, not a download cadence.
**`membership_sync` is the part that earns its keep.** Without it three very
different situations are one indistinguishable state — the account subscribes
to nothing, the sweep never ran, the sweep failed — and all three leave zero
rows in `platform_membership`. "You are tracking 12 sources you no longer
subscribe to" is correct in the first case and an invitation to cancel things
the operator is actively paying for in the other two. So C4 gates its
CONCLUSIONS on `last_success_at`, not merely its display, and `roster_is_fresh`
is computed server-side so no caller can forget to.
Two timestamps rather than one: `last_attempt_at` moves every run,
`last_success_at` only on a clean walk. The gap between them is the signal —
a sweep hammering a broken credential every day must not look healthy because
it ran recently, and there is a test for exactly that.
Rejected shortcuts, both tempting: `MAX(platform_membership.last_seen_at)`
cannot tell "synced fine, found nothing" from "never synced"; `task_run` is
worse, since its retention prunes ok rows after 24h and a sweep that last
succeeded three days ago would leave no trace at all.
**The fetch completes before anything is written.** That ordering is the safety
property: a walk that dies mid-pagination writes nothing, so a failure can
never leave a roster half this week's and half last week's. `touch_membership`
never deletes, so a failure cannot empty the roster either — but "intact"
should mean intact, not merely non-empty.
Rule 89's four, each where it actually lives: recovery is "run it again"
(upsert, no deletes); retention is C1's age-out-never-delete, because
disappearing IS the signal; the wall-clock deadline is per-platform and
distinct from the per-REQUEST timeout the client already has (rule 156 — a
paginated roster answering every page slowly-but-within-timeout would never
trip that one and would sit on a worker indefinitely); duration comes from the
existing TaskRun signal plumbing.
**A bug caught in review, not production:** the broad `except Exception` would
have swallowed Celery's SoftTimeLimitExceeded — which is an ORDINARY Exception
subclass, not a BaseException — letting the sweep run past the soft limit into
the hard one, where it is SIGKILLed mid-transaction. A sweep that cannot be
stopped is worse than one that fails. Now re-raised explicitly, with a test
that also asserts SoftTimeLimitExceeded is still an Exception, so the re-raise
cannot quietly become dead code.
Rule 164 is why this ships with UI rather than backend-only: a roster that
never synced must be VISIBLE as such. The card says "never synced" in words and
states no count at all — rendering it as 0 is the precise conflation the whole
step exists to prevent — while a real zero behind a real sync is reported as
zero, because that one IS an answer. Pinned in both directions.
Three independent gates decide whether a platform is swept — registered here,
client exposes `iter_memberships`, credential exists — each silent, so adding
SubscribeStar (D1) is one line and nothing else. A missing credential is not an
error: recording a failure would light up the UI for a feature never enabled.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
My scripted patch inserted the new stdlib from-imports after `import logging`
with a blank line between them, which isort reads as a group boundary.
Ruff-only failure — every test passed on 751e7dd, including the full
integration suite and migration 0095.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
**The verification the step asked for came back "not the schema".**
`Source.artist_id` is a plain FK so many sources per artist already works;
`POST /api/sources` already takes an `artist_id`; the add-source dialog already
has an artist autocomplete that attaches to an EXISTING artist; and
`SourceService.reassign` already moves a source between artists WITH post and
image re-attribution. A sweep for one-source-per-artist assumptions found only
`func.count()` calls — the opposite of assuming one.
So no parallel association table was built for a relationship the schema
already expresses (rule 28). What was missing is FC OFFERING the link, and that
is all this adds.
**Accepting adds a SOURCE. It never merges two artists.** That asymmetry sets
the whole posture: adding a source is trivially undone, while a wrong merge
silently mixes two creators' work and corrupts tagging, series and provenance
downstream with nothing left to tell them apart by. A test asserts the artist
count is unchanged by accepting.
The weights encode the judgement rather than a code path doing it — name 0.65,
declared 0.35, cut at 0.60 — so that:
* an EXACT name match alone proposes (same slug on both sides is strong, and
demanding corroboration would propose almost nothing);
* a CONTAINMENT match alone does not ("art" sits inside "artgirl"), and short
slugs are excluded from containment entirely because a 3-character slug is
inside a great many longer ones;
* the declaration ALONE never proposes, because a creator may link another
creator's Patreon and a link is not a claim of identity.
A guard test pins all three against WEIGHTS directly and says not to fix a
failure by moving the numbers.
Two corrections carried forward from earlier steps rather than rediscovered:
* The declaration is NOT read from `ExternalLink`. `SUPPORTED_HOSTS` is file
hosts only and `host_for()` returns None for patreon.com, so no row is ever
written for one — the same trap that caught E5 for Discord invites. It reads
the raw body, because these links live in an `href` and `html_to_plain`
discards attributes.
* `vanity` is not a column: C1 modelled the roster before any platform was
characterised, which is exactly what `details` exists for. `vanity_or_none()`
reads it from there and falls back to the URL's last segment, so a row
written before the field was understood still resolves.
Two fixes during the writing. `accept()` first created a bare `Source()`,
skipping the platform/URL validation, duplicate check and #693 backfill-arming
that a hand-added source gets — a second, quieter way to create a source is how
two paths drift until one is subtly broken; it now goes through
`SourceService.create`. And the candidate query used a bare `exists().where()`,
which has no FROM to correlate against; now `select(...).exists()`.
Chained onto the roster sweep rather than given its own beat entry: a
suggestion can only be as good as the roster behind it, so any other cadence
would just propose from staler data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
Two failures on 51e78a3, neither in the shipped logic.
**The integration failure was a TEST bug, not a code bug.**
`test_a_weak_name_needs_the_declaration` set `display_name` to something
deliberately weak but left `_membership`'s DEFAULT vanity, which matched the
artist slug exactly. So the name signal was legitimately 1.0 and the matcher
was right to propose — my assertion of 0 was asserting the wrong scenario.
Both identity fields now have to be weak for the test to mean what it says,
and the arithmetic was checked before pushing: 0.39 without the declaration,
0.74 with it.
Worth keeping: a fixture whose fields disagree with each other will pass or
fail for reasons unrelated to the property under test, and this one was one
default away from silently testing nothing.
**The lint failure was import order** — `artist_membership_service` sorts
before `credential_*` and I inserted it after. Second isort slip this session
from patching an import block with a script rather than reading it back; the
repo has a rule about exactly this (#102).
Everything else passed on that SHA: 1256 tests, the frontend suite, and
migration 0096.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
Reconciliation in Subscriptions, asymmetric on purpose. Subscriptions FC does not follow get a per-row add; sources the roster cannot account for are REPORT ONLY (operator decision) and link to the list on the same page. No one-click disable, so no disabled-reason column and no migration.
The membership<->source join lands as a SHARED resolver in membership_roster, not inline here: E4 now uses it as its negative check, so the two features cannot give different answers to 'is this membership already tracked?'. Keys on the exact cached campaign id FIRST and the URL handle only as fallback, because the id is written only after a source has been walked once. Read via any <platform>_campaign_id override rather than naming Patreon's, per rule 169.
The report-only bucket is gated on roster freshness and carries a per-row basis, so 'your membership says former patron', 'we know this id and it is absent', and 'we only have a handle' stay three different sentences. has_paid_access None never reads as lapsed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
Source.config_overrides carries two unrelated things under one column: the operator's per-source download settings, and state FC writes for itself. update() treated the whole column as operator-owned and assigned it wholesale, so a dialog save discarded patreon_campaign_id and the entire #693 backfill state machine. Not a hand-edited-JSON edge case: SourceFormDialog's structured tab rebuilds the object from two fields, so saving without touching anything was enough.
_merged_config now merges: the operator's keys replace wholesale (removing a key must still remove it), FC's keys survive and are applied LAST so a stale echoed cursor cannot roll a walk backwards. App-managed is _-prefixed or *_campaign_id, both matching data already on disk, so no migration.
Preserving the id exposed a bug the wipe was MASKING: nothing cleared it when a source's URL changed, and patreon_resolver reads that cache before attempting any lookup. A repointed source would have resolved the old creator forever and 387 C4 would have reported a confident wrong match. So update() drops *_campaign_id when the URL actually changes - but keeps the backfill cursor, which the walk's own stall guard validates and which is expensive to rebuild.
test_update_changes_fields was DOCUMENTING the bug: it asserted config_overrides == {videos: False} on a source whose create() had armed _backfill_state, so it could only pass because the state had been destroyed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
MembershipRosterCard had grown its own ago(iso) helper, a near-copy of utils/date.js::formatRelative. C4's card was about to become a third copy before a hook caught it. Both now use the shared helper.
The hand-rolled copy was also slightly wrong in ways the shared one is not: it floored everything under a minute to '1m ago', and would have rendered NaNm ago for a null timestamp had a caller ever reached it without a v-if guard. Only sub-minute output changes, which no spec exercises - membershipRosterCard.spec.js seeds its rows at exactly 1h and 9d, where both helpers agree, and asserts on literal phrases rather than time strings.
Recorded formatRelative as snippet 3959 so the next component is offered it instead of deriving a fourth copy.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9
A3 made a tier-gated source say "47 posts you can't see". The roster turns that into a reason: the membership ended, or the tier doesn't reach these posts, or it's a free follow. Rendered under A3's count in the health tooltip, quieter than the count it explains.
FREE is a fourth case the step didn't enumerate, and it earns its own sentence. has_paid_access collapses "former patron" and "current free follower" to the same False, so deriving the reason from that boolean would tell a free follower "you're not a patron any more" - a false statement about a state they were never in. gated_reason reads the status axis first, calling has_paid_access with is_free_member forced off, then splits on the free flag.
Silence is the default, and there are four ways into it: campaign absent from the roster, roster stale, platform never swept, status word not yet characterised. All four send null and the count stands alone. The frontend has no fallback sentence either - a default would turn "we don't know why" into a reason, which is the one thing this step must not do.
The line that must not be crossed is pinned structurally rather than by inspection: test_no_fetch_path_can_read_the_roster walks the transitive first-party imports from the fetch roots and asserts the roster is unreachable. FC runs no local verification (rule 85), so a guard cannot be falsified by hand before it lands - it carries two positive controls instead, proving the walker finds roster imports that ARE there, one direct and one through a hop, so the real assertion can never pass merely because the walk resolved nothing.
C4's identity loop moved to membership_roster.pair_sources_with_memberships when C5 became its second caller; two copies would let the Subscriptions row and the reconciliation card disagree about which creator a source IS. Three test files were each building PlatformMembership rows with their own drifting helper - consolidated into tests/roster_builders.py, same family as issue 3109.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
Two of C5's three structural tests errored in CI with ValueError: PosixPath('.') has an empty name. The walk special-cased a package's __init__.py, dropping the __init__ component before computing what `from .` refers to — which made `api/__init__.py`'s `from . import health` resolve to the app root instead of to `api`, and `celery_app.py`'s `from . import celery_signals` resolve to the empty string, which is what actually crashed.
The special case was never needed: `parts[:-1]` already gives the CONTAINING package for both forms, because `services/foo.py` drops `foo` to leave `services` and `api/__init__.py` drops `__init__` to leave `api` — exactly what `from .` means inside each. The level slice is now clamped at 0 as well; an import climbing past backend/app left the tree, and the unclamped negative index wrapped and resolved to the wrong module rather than to nothing.
A module directly under backend/app doing `from . import x` still yields no package prefix, and there the alias alone IS the dotted name - handled explicitly rather than by falling through into a Path built from an empty string.
The two positive controls earned their place immediately: they are what failed. Without them the walk would have resolved almost nothing and test_no_fetch_path_can_read_the_roster would have passed on a broken walker, reading as coverage.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
Two surfaces render a post's HTML body. get_post did _localize_inline_images(sanitize_post_html(...)); provenance_service._post_dict called sanitize_post_html alone. So opening the Provenance panel fetched images from Patreon's CDN for files FC had already downloaded - the archive reaching out to the platform to display what it had archived, which is the thing 830 Phase 2 set out to stop. The same bodies also break when a CDN URL expires or a post is removed, while the identical local copy sits unused.
The fix is the shape, not the call. Those were two separately-callable halves and only the first looked mandatory, so a second caller was always going to do half of it. render_post_body in the new services/post_body.py is the whole pipeline in one call, and sanitizing-without-localizing is no longer a reachable operation. _localize_inline_images moves there verbatim; post_feed_service loses five imports that went with it.
_post_dict becomes async and takes the session. Both call sites are already inside async methods, so for_image's list comprehension awaits per entry - fine, because localization issues ZERO queries for a body with no inline <img>, which is most of them. Recorded that early exit in the module so the loop isn't "optimized" into a batch without a measurement.
Deliberately NOT fixed: provenance still names the same columns url/title/date where the feed says post_url/post_title/post_date, and its description_translated is full text where the feed truncates to DESCRIPTION_LIMIT. That is 3965's wider half - a breaking payload change for ProvenancePanel with no second reason to spend it today. Noted in _post_dict's docstring so the next reader knows it was seen and left.
Four regression tests on the provenance path, covering both entry points plus the two refusals the feed already pins: an uncaptured image stays hotlinked (a broken local path is worse than an intact remote one), and a filehash owned by another artist never leaks in. Reverting render_post_body to a bare sanitize fails the first two.
Recorded as snippet 3968.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
Membership moves from patreon_client to native_ingest_common, beside PostRecordOutcome, for exactly the reason that one lives there: it is the seam's contract rather than the first platform's. Left where it was, D1 would have had to import the shape it implements from the module of the platform it is being mirrored FROM - which inverts the dependency and is how a seam advertised as portable quietly stays Patreon-shaped.
Found by C7's own pass, which is the point of running C7 before D1 rather than writing it up afterwards: this is invisible while there is only one implementer and load-bearing the moment there are two.
No behaviour change. Three files, no shim (rule 122): patreon_client imports it, the dataclass keeps its docstring, and the test imports from the seam's home. The docstring gains what the contract owes a second platform - that a missing field supplies the empty answer and never a guess: no tiers -> [], no pledge -> None (absent stays distinguishable from zero, since "free" and "we don't know" are different answers), no vanity -> None with identity falling back to the URL tail.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
B4's on-ramp read "add a credential, add a source" - the manual path, and the one that makes a new installer retype creators they have already told Patreon about. With C4 in place the app can just look them up. This is the step where the milestone's original framing actually lands on screen.
Four rungs, chosen by ONE predicate rather than four independent v-ifs. The rungs are mutually exclusive by construction and the rung shown matches what is actually POSSIBLE:
no credential -> Add a credential. Discovery is not offered, because a button that cannot work is worse than its absence.
credential, never synced -> Find what you already subscribe to. Queues the C3 sweep.
synced, unmatched > 0 -> the count, linking into C4's bucket 1.
synced, nothing unmatched-> the manual add, because there is genuinely nothing to discover.
The fifth state is rule 164's. A sweep that has been ATTEMPTED and never succeeded reads as "couldn't reach patreon", with the error type and the manual path still open - not a spinner, not a crash, not a retry button that will fail identically. An install with no outbound network lands here.
That is deliberately narrower than "there is an error": a roster that synced once and failed since is NOT unavailable. It has a roster, just an ageing one, and C4's freshness gate already handles that. Collapsing the two would hide a usable roster behind an error banner.
Still offer, never auto-add. An empty front door is exactly where "just add all thirty" is most tempting and most wrong - thirty backfills on first boot - so the rung carries the count and sends them to the picker.
No new machinery: three existing stores (credentials, membershipSync, membershipReconcile). Reconcile is fetched only once something has synced, since bucket 1's count is meaningless before that. Every load swallows its failure, because this screen renders on an install that can reach nothing.
Ten tests on top of B4's, including the mutual-exclusion property asserted directly. Its phrases are each pinned to a single template line and both sides whitespace-normalised - a phrase spanning a line break would never match, and a mutual-exclusion check whose phrases never match passes vacuously (rule 167).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
3422 was already fixed. Commit 86abaf0 applied option 1 in full and it is on main: .env.example carries the bootstrap section with the backup warning, README explains the refusal and why it is deliberate, docker-compose forwards the variable, and the milestone-362 smoke gate that FOUND the bug now sets it (build.yml:1159) and passes. The issue's premise - "CURATOR_BOOTSTRAP_NEW_KEY appears nowhere outside backend/" - is stale.
What was left is the dependency that fix created. README.md and .env.example now both print the literal error text, the literal key path and the variable name, because a stranger greps for the string their terminal showed them. That is the right call and it means two user-facing files now depend on this module's wording with nothing connecting them - the install surface's characteristic defect, one rename away from a README that sends strangers to a path that does not exist.
Three guards, all presence checks on both sides. An absence check against prose would pass for the wrong reason the moment a sentence were reworded (snippet 3352):
- the raised message still contains the sentence README reproduces, the variable both docs say to set, and the restore-rather-than-mint alternative the whole refusal rests on;
- both docs still name _CREDENTIAL_KEY_PATH and the variable, read from the code rather than retyped, so a rename fails here;
- compose still forwards the variable - without that line the docs' "set it in .env" is silently inert and fails identically to not setting it.
Option 2 (mint when the credential table is empty) is deliberately NOT done. The issue's own guidance is "(1) now, (2) if the friction proves annoying", and the friction has not been reported. Worth recording that its predicate checks out exactly: the Fernet key protects Credential.encrypted_blob and nothing else - no other Fernet user exists - so "no credential rows means nothing can be made undecryptable" is provable rather than probable. The cost is placement: create_app() is sync and constructs the key before any engine exists, so the check cannot live where the failure is. entrypoint.sh, which already runs alembic against the DB, is the natural seam. Only the web role is affected; the Celery roles build the key lazily inside tasks.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
The second platform through the seam note 3970 contracted, characterized first from a live capture of the account's /subscriptions page (note 3989). The capture lives in the gitignored captures dir; the committed fixture is hand-built with invented values and was verified tag-for-tag against it - card wrappers, both table heads, and every distinct row shape - before any code depended on it.
What the page is, and the three decisions it forced:
The table IS the status. SubscribeStar has no per-row status word: a creator is either in the active_subscriptions card or the cancelled_subscriptions one. The card's data-identifier is stored verbatim as Membership.status and mapped in MEMBERSHIP_STATUS, keyed on the identifier rather than the table class because the cancelled table's class names the same list differently (for-unsubscribed_users).
The creator's numeric data-user-id is the key, not the slug. A slug re-keys when a creator renames; the old row stops appearing; and a disappearance is exactly what reconciliation reads as a lapse. Keyed on the slug, a rename would have told a paying subscriber they had cancelled. The slug rides as vanity, where the identity join already looks for a handle.
Price is kept as text, never parsed into amount_cents. A bare $ names no currency and a page price is not proven to be the charge - 3970 finding 4. Tier names live behind a per-row modal and are not fetched.
Refusals, because SubscribeStar offers nothing like Patreon's meta.pagination.total and every conclusion downstream is drawn from absence. The parser raises when: the active card is missing (auth error on a login/age wall, drift otherwise); a row lacks a numeric creator id or a creator link; anything renders after a card's table; or the page carries a page= link. Both cards are paginatable (app#embed_pagination) and the captured account was too small to show what pagination looks like, so possible pagination is a roster FC cannot prove complete. A loud error on a larger account beats a quiet half-list. A missing cancelled card is not drift, and a creator in both tables is reported once, as active.
Fetched from subscribestar.adult, not the .art the capture came from: FC's requests never clear the .art age wall with the 18+ cookie (1259, 1284). Whether /subscriptions on .adult authenticates exactly as .art did in the browser is untested - if not, the sweep records a visible error and C6 shows its unavailable rung.
The seam leak D1 found. Note 3970 promised a second platform would be one builders line plus the client method. The sweep instead called current_user_id() on every client, which only Patreon's has, so SubscribeStar would have raised AttributeError on the first sweep. roster_user_id probes it with getattr, the same way the sweep already probes iter_memberships.
Two existing tests were passing for the wrong reason and now can fail:
- "a platform that has never been characterised says nothing" named SubscribeStar, and stayed green only because active_patron is not a SubscribeStar word. Now uses hentaifoundry, with a positive SubscribeStar test beside it.
- the freshness test gave SubscribeStar a Patreon word, so the vocabulary excluded it and deleting the freshness gate outright would have left it green. It now uses cancelled_subscriptions, making the gate the only thing that excludes it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
ef91fcf failed ruff's B905 lane on one zip(labels, cells) without strict=. Tests, integration and the frontend were already green on that SHA.
strict=False is the deliberate side, not the quiet one. strict=True raises a bare ValueError - not SubscribeStarDriftError - and would fail the whole roster sync over a column mismatch in `details`, which nothing reads yet. That would take down reconciliation and the gated-post reasons over a cosmetic markup change, while creator identity (id, slug) never depended on the columns at all.
But a shifted column would mislabel details silently (a price filed under "discord"), so a count mismatch now logs a canary warning, mirroring the feed parser's existing parse canary: diagnosable from the worker log, never fatal.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
Milestone 406 retires pixiv (rule 171) in two phases at the operator's explicit ask: switch it off, then later delete its code. This is the switch-off. Steps 2 and 3 ship together because each is a half-state of the other: unregistered but still in the extension, pixiv creator pages would offer a button the backend then refuses.
Reachability removed, never gated (rule 22 - no flag, no `if platform == "pixiv"`):
- platforms registry: pixiv unregistered, so /api/platforms, the source validator and quick-add all refuse it through their existing unknown-platform paths.
- NATIVE_INGESTER_PLATFORMS: pixiv removed.
- extension_service: pixiv's quick-add URL pattern removed (the Python half of the JS mirror).
- extension: pixiv's host permissions, content-script match, platform entry and artist pattern removed; popup's pixiv branches removed; and the whole pixiv PKCE OAuth flow cut out of background.js. That last one could not wait for phase 2 - a webRequest listener on a host the manifest no longer grants is at best dead and at worst a startup failure for the entire background script. On startup the extension now also removes any pixiv refresh token a browser still holds in storage, for the same reason as the server-side credential cleanup (3980).
- frontend: the extension card stops listing pixiv; SourceActions' copy of the native list drops it. platformColor keeps rendering a pixiv key so existing pixiv posts do not look broken.
The guard, and why a registry change alone was not enough. A source outlives its platform: the live instance still had one ENABLED pixiv source (step 1). Tracing it: the scheduler only selects enabled rows and every platform lookup uses .get(), so a disabled row is inert - but re-enabling it and pressing Check would have routed pixiv, no longer native, straight into the gallery-dl branch, which still has a pixiv extractor. And a worker can pick up a still-enabled row before a deploy's migration runs. So run_download and verify_source_credential - the two functions every download and credential probe pass through - now refuse any platform not in the registry: an unsupported_url failure for downloads, and an inconclusive (None, not False) verify, since nothing was probed so nothing was rejected. Generic by registration, so it covers deviantart's leftovers too. Positive-controlled: a supported gallery-dl platform must still reach gallery-dl, or a guard that refused everything would pass (rule 167).
Migration 0097 disables sources on retired platforms (pixiv, deviantart) and clears their failure state exactly as disabling through the app does (1285), so the stale row stops being scheduled and stops showing as failing. Nothing is deleted: removing a source can collide with uq_post_artist_external_id_null_source on real data, which is phase 2's step 6 to check. No post or image is touched.
Tests: the known-platform lists drop pixiv and gain retirement assertions beside deviantart's; pixiv's positive extension cases become negative guards; the pixiv sidecar post-URL test is deleted with the behaviour it tested; quick-add rejects a pixiv URL. The pixiv client/downloader/ingester suites stay - that code stays until phase 2.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
Ships with the switch-off rather than with the code removal: a doc that promises a platform the product refuses is the Install and Public Surface area's characteristic defect.
pixiv comes out of README (twice), SECURITY.md (twice), .env.example and the compose header. The stored-credential warnings now name Patreon and SubscribeStar - the accounts that usually carry a payment method.
One correction beyond pixiv. README said FabledCurator follows creators on Patreon, SubscribeStar, Pixiv "and anything gallery-dl supports". That was already false: a platform not in the registry is rejected, however capable gallery-dl is. It now names the real set, which rule 171 records: Patreon, SubscribeStar, Discord and HentaiFoundry.
The 3422 docs guards still hold - the key path and bootstrap variable are untouched.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
e3fd8c6 failed two lanes, both on things pixiv's retirement correctly started refusing.
Unit - test_fe_be_contract pins platformColor.js's ICONS keys to known_platform_keys(). I had kept pixiv's icon, colour and label "so existing pixiv posts don't look broken". That was wrong, and the file's own header already said why: unknown platforms fall back to a grey chip with the raw key, and that fallback is deliberately what a retired platform hits - it is how a pre-3069 deviantart row already renders. So pixiv leaves all three maps, and its posts show as a grey "pixiv" chip rather than a broken one. The header now says outright that a retired platform is removed, never kept, since the milestone plan itself got this backwards.
Integration - four reassign tests built their fixture source through SourceService.create with platform="pixiv", which the validator now rejects. Reassign never reads the platform and never moves files, so any registered platform serves; the fixture uses hentaifoundry. 1294 other integration tests passed on e3fd8c6, so nothing else used pixiv through a validating path.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
37 commits from
devsince #250 (2026-09-02). The operator asked for this merge so the work can be tested. CI passed on the head commite3c516d: ci run 6704 (616 unit, 1298 integration, lint, frontend) and build run 6703. The extension checks passed onc2f9e9c(run 6702), and nothing extension-side has changed since.Milestone 387: why posts are missing, and what you subscribe to
Milestone 388: Discord
Milestone 406, phase 1: pixiv retired
Fixes
Deploy notes: migrations 0091 → 0097 run on startup
platform_membership,membership_sync,post_association,artist_membership_suggestion) and new columns onpost,ml_settingsandimport_settings. Every drop is in a downgrade.enabled = falseon any still-enabled pixiv or deviantart source and clears its error state. No row, post or image is deleted. Its downgrade is a deliberate no-op..env.example/docker-compose.ymldiff is two comment lines that drop the word Pixiv.🤖 Generated with Claude Code
https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9
Discord is a delivery channel, not a publisher. One message is not one post, and today every message lands as its own `post` row, so chat lines compete with authored work for the same surface. Rather than demote them into a second-class feed, FC now writes the post itself: one row per DROP, its images the drop's images, its body the messages' text in arrival order. Synthesising a `Post` (rather than inventing a parallel entity) is the whole point — the result is post-shaped by construction, so feed, provenance, translation, attachments and series keep working on it unchanged. The predicate is three axes ANDed, and the time one does the real work: same source AND cosine distance <= threshold AND no gap > window Similarity alone over-groups, and that is the failure that would make this useless: any two pieces of the same character by the same artist sit close in SigLIP space, so a cosine-only rule collapses a month of one character into a single "post". Two details inside the predicate are load-bearing — * distance is measured to the group's SEED, never to the previous member, because chaining lets a group DRIFT: twenty small steps walk from one piece to a completely different one, each hop individually within threshold; * the window is measured between CONSECUTIVE messages, not from the first, so an artist trickling variants out over an evening stays one drop. Why a post-import sweep and not part of ingest. The obvious alternative was to migrate Discord to the native post-first ingester (#1266) and group at capture time. That cannot work: the grouping signal is `siglip_embedding`, which is produced asynchronously AFTER import (tasks/ml.py, the GPU backfill), so at capture time there is nothing to group on. Grouping is necessarily something that happens once the vectors catch up — hence a re-runnable sweep that skips what it cannot yet place, and an hourly (not daily) cadence. The honesty rule, enforced in the schema. `post.synthesized_by` names the grouper; `synthesis_details` records the members, the count, and the thresholds AS THEY WERE (they are operator-tunable, so without that "why did it group these" is unanswerable a month later). Member posts are absorbed, not destroyed — they remain the images' true origin and the audit trail — and `absorbed_by_post_id` is ON DELETE SET NULL, so deleting a synthetic post releases its members back into the feed in one DELETE with no repair step. `post_title` stays NULL deliberately: a synthesised title is the one place this could put words in a creator's mouth. Two guards the first draft would have failed: * the per-run cap took the lowest post IDs, not the oldest posts — DISTINCT ON forces its own ORDER BY, so the sort now happens outside the subquery; * a cap landing mid-drop would have published a truncated group claiming to be a whole drop, so the last group is left for the next run. And one vacuous test caught before it shipped: the support vector perturbed a single component of an all-ones vector, moving it ~1e-6, so every distance assertion passed regardless of what the predicate did. `_vec` now builds a unit vector at a stated angle, where distance is exactly 1 - cos(delta) — rule 167, a guard has to be able to fail. UI (rule 27) follows in the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9Rule 27 — E2's other half. The backend can author posts; this is what makes that visible and adjustable. **The honesty marker.** A chip on every synthetic post's card: "grouped by FabledCurator", titled with what it was built from ("Grouped from 4 Discord messages"). This chip is the only thing standing between "FC assembled this" and the card reading as something the artist authored, so it keys off nothing but the flag, and it states the member count rather than just disclosing that grouping happened — a claim you can check beats a claim you're asked to trust. A synthetic post has no title on purpose (inventing one is the one place this feature could put words in a creator's mouth), and the untitled fallback would otherwise have printed the internal key: "Post fc-drop:99887766". It now names the post for what it is. There's a test for that specifically. **The tuning card.** Ingestion & filters gets a Discord-drop-grouping tile: the switch, the distance cut and the drop window, each with the sentence that tells the operator which one to reach for. The window's copy says outright that it is the setting doing most of the work — without it, everything an artist ever drew of one character collapses into a single post. Both directions are pinned in postCard.spec.js, including the one that actually matters: an ordinary post is never marked. Also covered — a post dict composed before these fields existed degrades to unmarked rather than throwing on `synthesis.message_count`. Also fixes the ruff UP017 that failed the lint job on73eeb7a(timezone.utc → datetime.UTC, the convention everywhere else in this repo). The integration suite on that SHA was green: all 12 grouping tests passed and migration 0092 applied cleanly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9Rule 27 — E5's other half. The matcher can propose; this is where the operator decides, and where an accepted link actually shows up. **The review queue** (Settings → Ingestion & filters). Each proposal shows the per-signal breakdown, not just the total: "why did it suggest this" is the question the operator actually has, and a lone percentage cannot answer it. So a row reads "72% · timing 95% · says so 60%", and the copy states outright that a pair always needs two reasons — which is the property that stops a busy posting day from producing false pairs. The empty state says so explicitly. Nothing proposed is the EXPECTED state most of the time, and an empty queue that looks like a failure invites turning the threshold down until it produces noise. **On the card**, both directions, and accepted links only: the teaser gets "The full set is in Discord", the drop gets "Announced on Patreon". A pending proposal is a question for the review queue, never a claim to render beside the artwork — that distinction is the whole confirm-only design, so it is asserted in the backend (only `linked` rows reach the payload) and again here. One detail worth the comment it carries: the link's target is `{ query: { post_id } }` with no name or path. In vue-router that means "the current route with these query params", so it works identically from Latest and from Browse — and, more usefully, the card never reaches for `useRoute()`, which it has no other reason to know about and which is not available when it is mounted in a test without a router. Backend CI on235393cwas green: all 13 E5 tests and migration 0094. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9**The verification the step asked for came back "not the schema".** `Source.artist_id` is a plain FK so many sources per artist already works; `POST /api/sources` already takes an `artist_id`; the add-source dialog already has an artist autocomplete that attaches to an EXISTING artist; and `SourceService.reassign` already moves a source between artists WITH post and image re-attribution. A sweep for one-source-per-artist assumptions found only `func.count()` calls — the opposite of assuming one. So no parallel association table was built for a relationship the schema already expresses (rule 28). What was missing is FC OFFERING the link, and that is all this adds. **Accepting adds a SOURCE. It never merges two artists.** That asymmetry sets the whole posture: adding a source is trivially undone, while a wrong merge silently mixes two creators' work and corrupts tagging, series and provenance downstream with nothing left to tell them apart by. A test asserts the artist count is unchanged by accepting. The weights encode the judgement rather than a code path doing it — name 0.65, declared 0.35, cut at 0.60 — so that: * an EXACT name match alone proposes (same slug on both sides is strong, and demanding corroboration would propose almost nothing); * a CONTAINMENT match alone does not ("art" sits inside "artgirl"), and short slugs are excluded from containment entirely because a 3-character slug is inside a great many longer ones; * the declaration ALONE never proposes, because a creator may link another creator's Patreon and a link is not a claim of identity. A guard test pins all three against WEIGHTS directly and says not to fix a failure by moving the numbers. Two corrections carried forward from earlier steps rather than rediscovered: * The declaration is NOT read from `ExternalLink`. `SUPPORTED_HOSTS` is file hosts only and `host_for()` returns None for patreon.com, so no row is ever written for one — the same trap that caught E5 for Discord invites. It reads the raw body, because these links live in an `href` and `html_to_plain` discards attributes. * `vanity` is not a column: C1 modelled the roster before any platform was characterised, which is exactly what `details` exists for. `vanity_or_none()` reads it from there and falls back to the URL's last segment, so a row written before the field was understood still resolves. Two fixes during the writing. `accept()` first created a bare `Source()`, skipping the platform/URL validation, duplicate check and #693 backfill-arming that a hand-added source gets — a second, quieter way to create a source is how two paths drift until one is subtly broken; it now goes through `SourceService.create`. And the candidate query used a bare `exists().where()`, which has no FROM to correlate against; now `select(...).exists()`. Chained onto the roster sweep rather than given its own beat entry: a suggestion can only be as good as the roster behind it, so any other cadence would just propose from staler data. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LNXXULQDjVZmbuNa2G9mD9Two of C5's three structural tests errored in CI with ValueError: PosixPath('.') has an empty name. The walk special-cased a package's __init__.py, dropping the __init__ component before computing what `from .` refers to — which made `api/__init__.py`'s `from . import health` resolve to the app root instead of to `api`, and `celery_app.py`'s `from . import celery_signals` resolve to the empty string, which is what actually crashed. The special case was never needed: `parts[:-1]` already gives the CONTAINING package for both forms, because `services/foo.py` drops `foo` to leave `services` and `api/__init__.py` drops `__init__` to leave `api` — exactly what `from .` means inside each. The level slice is now clamped at 0 as well; an import climbing past backend/app left the tree, and the unclamped negative index wrapped and resolved to the wrong module rather than to nothing. A module directly under backend/app doing `from . import x` still yields no package prefix, and there the alias alone IS the dotted name - handled explicitly rather than by falling through into a Path built from an empty string. The two positive controls earned their place immediately: they are what failed. Without them the walk would have resolved almost nothing and test_no_fetch_path_can_read_the_roster would have passed on a broken walker, reading as coverage. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SHQB1YukL3VyvMK8rcbmV9