Skip to content

Add cross-module mentions, one canonical display name, and stop publishing email addresses - #692

Merged
ddon merged 24 commits into
BeamLabEU:mainfrom
mdon:main
Aug 9, 2026
Merged

Add cross-module mentions, one canonical display name, and stop publishing email addresses#692
ddon merged 24 commits into
BeamLabEU:mainfrom
mdon:main

Conversation

@mdon

@mdonmdon commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Cross-module @/# mentions, one canonical display name, and the plumbing two
modules needed to stop leaking email addresses onto public pages.

Rebased onto upstream/main today. Both of this branch's migrations were
renumbered during that rebase
— see "Migration chain" below.

What's here

Mentions (V165)@[user:uuid|Label] / #[type:uuid|Label] tokens that
survive markdown, sanitisation and copy-paste, a typeahead any textarea can
opt into (use PhoenixKit.Mentions.Live), a reverse index for "what links
here", and access requests for a record the reader can see named but not open.
Visibility is federated to whichever module owns the type and fails closed:
a type that declares no check resolves to nothing rather than everything.

User.display_name/1 — one answer to "what do we call this person in
front of other people": organisation name → first+last → username → the local
part of the email → "User". Never the full address.

This replaced four private copies, two of which ended || user.email. One of
them, Users.CommentResources, is the registered ResourceLinks handler for
the "user" type — so it names every@ mention there is, and a public
issue board was rendering commenters' full addresses next to their words:

%{state: :ok, title: "nameless-5954@example.com"}

Only fields the user wrote about themselves feed it. A name recorded by
somebody else — an HR record, a CRM label — never reaches it.

withhold_titles (V166 adjacent) — mentions_redact_titles is off by
default, which is the right call for internal readers and says nothing about
the open web. Both audiences share one renderer, so a public surface can now
withhold on its own account: an out-of-scope mention renders with no title
and no fallback to the author's stored label (the label is the record's name
as of writing — the same leak, one revision staler).

Frozen comment attribution (V166)author_display_name,
attribution_mode, attributed_project_uuid, attributed_label on
phoenix_kit_comments. A name resolved at render time rewrites history:
someone leaves or fills in a profile and every comment they ever wrote is
silently re-signed. user_uuid is never cleared — posting under a project's
name changes what the public sees and nothing else, so moderation and audit
keep their actor.

Upload sanitiserImageProcessor.sanitize/3 re-encodes rather than
re-serving: coder forced from the sniffed type, -strip, resource -limits,
and a 40 MP cap beforeconvert is invoked (-resize bounds the output; the
decoder still rasterises the input in full, so a 5 MB PNG declaring
50000×50000 is ~10 GB before a pixel is written).

Smaller fixesfile_upload wrapped itself in a <form> unconditionally,
so any host embedding it got nested forms and the browser silently moved every
field after that point out of the outer form. Orphan detection learned about
JSONB-referenced attachments, which it would otherwise have collected as
unreferenced while they were live on a public page.

Migration chain

This branch originally used V163 and V164. Upstream claimed both while
it was local (UUID primary-key integrity; the V56/V57 flush-order repair), so
the rebase renumbered them to V165 (mentions) and V166 (attribution),
including their down/1 markers. release_check confirms the result:

PASS current_version/0 == v166.ex
PASS min(vNN.ex on disk) == initial_version/0 (V135)
PASS V135..V166 contiguous (32 versions), every module loadable

⚠️ The manifest is stale, deliberately

ExpectedSchema.chain_hash/0 no longer matches, so these two fail:

  • check_manifest_chain_hash/0
  • check_migration_sync/0 (only on the hash assertion — floor, contiguity and
    loadability all pass, as above)

Per dev_docs/squash/README.md, this is expected and not something to fix
in this PR:

A migration PR may land with a stale manifest; the chain_hash assertion in
release_check and its plain-unit-test twin are the release-time gate —
regeneration must happen before publish.

Regeneration has to run from a pre-squash checkout (on the squashed branch
the generator is self-referential and drops pre-floor drift), and
restamp_chain_hash.exs is explicitly the wrong tool here — it would make the
hash pass over a manifest that has no knowledge of these objects, hiding the
drift the hash exists to detect. Hand-merging the manifest is forbidden by the
same policy. So: regenerate before publishing, please.

Tests

3259 tests, 2 failures — the two above.

Run with --max-cases 4. At full concurrency the integration suite produces
deadlock_detected failures in a different handful of tests each run; the same
happens on a clean upstream/main worktree, so it is pre-existing and
unrelated to this branch.

mix precommit exits 0.

mdon added 24 commits August 9, 2026 17:31
A disabled tbody kept emitting data-sortable-event/-items/-handle —
inert without the hook, but the literal ".pk-drag-handle" string broke
consumers' honest substring assertions (phoenix_kit_projects' DnD
load-more-truncation test refutes "pk-drag-handle" while non-draggable)
and shipped meaningless attributes to the client. The component's own
doc already promised "renders identically but without drag behavior".
All four sortable attrs now key off @enabled; the disabled-state test
pins their absence.
The activity→notification bridge resolves ONE target_uuid per entry;
events whose audience is a SET (project members, watchers) had no path
that respects the whole machinery. fan_out_from_activity/2 re-routes a
committed entry per recipient — prefs, per-channel routing, digest
cadences, and the actor self-skip all apply — WITHOUT inserting extra
activity rows: the feed keeps one canonical entry and deliveries still
key on its committed uuid. Recipients dedupe; results return in order.
First consumer: phoenix_kit_projects' event notifications (all project
members). Tested: per-recipient fan-out with actor skip + zero new feed
rows, and per-user pref honoring.
Feature modules that reference users by UUID (memberless orphans,
ownership succession) need a pre-delete signal. delete_user now calls
each injectable module exporting before_user_delete/1 with the user's
uuid before the delete transaction; per-module rescue/catch logging so
one module can never block the deletion.
External modules' user_dashboard_tabs/0 entries carrying a live_view
now generate their /dashboard/* routes in the authenticated route
table, mirroring compile_module_admin_routes — the :user_dashboard
context clause in tab_callback_context/1 existed but nothing called
it. Tabs without live_view (the hardcoded shop/billing/tickets blocks)
are untouched. First consumer: phoenix_kit_projects' member surface.
…tics
Panel follow-ups: catch kind/reason so a throwing hook cannot abort
deletion; document that hooks deliberately run outside the delete
transaction (their writes must survive; rare post-hook delete failures
are admin-recoverable).
Final-panel find: without :peer_data in connect_info,
get_connect_info(socket, :peer_data) is nil on connected mounts, so
per-IP abuse buckets (the projects portal) collapse into one shared
bucket. Host apps should mirror this in their own endpoints.
The old component could never have worked: its class attribute was a
plain string with un-interpolated \#{} calls to functions that don't
exist, and it depended on .accordion-* CSS no host defines. Rebuilt on
the native <details> + daisyUI collapse with an optional server-tracked
mode (open + toggle_event) — without it, morphdom re-applies the
server-rendered state on every patch and a section the user opened
slams shut mid-use (found on the projects creation form, its first
consumer).
Make the client the single authority on the details open state:
JS.ignore_attributes("open") on mount stops morphdom from resetting
a section the user toggled, and with no server round trip fighting
the native toggle, daisyUI 5's ::details-content height transition
finally runs — sections slide open and shut instead of snapping.
The server-tracked mode (toggle_event/toggle_value) is gone; open
now sets only the initial state, and pages open a section from
elsewhere via JS.set_attribute.
Closing an expanded collapse near the bottom of a long page shrank the
document, the browser clamped the scroll position to the new maximum,
and the viewport snapped upward under the reader. Pad the document with
a body spacer just before the collapse so it cannot become shorter than
their current position: they stay looking at the section they just
closed, with blank space below the fold. Each pixel they scroll up
releases a pixel of spacer — height leaving from entirely below the
viewport is invisible — so the page works its way back to its natural
length without ever moving underfoot.
Measured on the summary activation rather than the toggle event, since
by then the element may already be collapsed (prefers-reduced-motion),
and released against the reader's scrolling rather than the live
document height, which mid-collapse still contains the content on its
way out and reads as though the spacer were unnecessary.
The held height was going on a body-level div, below <main> and outside
the drawer entirely. The admin sidebar is a sticky grid item, so it can
only extend as far as its grid row: the row kept its old height, the
sidebar stopped dead at the previous page bottom, and the blank area
below showed bare body with a sidebar cut off mid-item.
Give the layout an explicit pad as the last child of the content column
and let the keeper prefer it — growing that cell grows the grid row, so
the sticky sidebar and the page background carry on through the held
space. It is server-rendered so morphdom keeps it, with style handed to
the client so the height survives patches. Layouts without the marker
still get the injected body spacer, which holds the scroll position the
same way but leaves the blank area outside the app chrome.
When a click in one place changes something in ANOTHER — picking a
starting point that rewrites a capability checklist, enabling an
extension that adds a permission row — the change is real but silent,
because it lands inside a collapsed section the reader isn't looking at.
push_event(socket, "pk-flash", %{ids: [...]}) now briefly highlights those
sections. LiveView dispatches pushed events on window as phx:<name>, so
this needs no hook and no per-element wiring: any module can say which
sections its click just changed.
Restarts the animation when the same section flashes twice in a row —
re-adding a class an element already has does nothing, so a rapid second
change would have looked like nothing happened. Honors
prefers-reduced-motion: same outline, no pulse.
Highlighting a whole section tells someone already looking at it nothing,
and highlighting a closed section raises a question it can't answer: the
reader opens it and everything looks the same.
So granularity follows what the reader can see. Section open: flash the
changed ROWS. Section closed: flash the section, remember the rows for ten
seconds, and if they open it in that window the rows flash then. A change
touching more rows than six flashes the section instead — past a handful,
individual highlights read as strobing rather than as information.
The replay waits a beat after the toggle so the highlight doesn't play
against content that is still zero-height mid-reveal.
Four AIs reviewed the first version. Two of their corrections changed the
design, and both were right.
THE SERVER STOPPED DECIDING WHERE. It sent section ids plus row ids; it
cannot know which sections are open, so it was guessing at a decision only
the client can make. It now sends only WHAT changed. The client resolves
each id, walks up to its [data-change-region], and picks: region open,
highlight the rows; region closed, highlight the region.
THE TIMER BECAME A MARKER. "Remember for 10 seconds" turns "what changed
in here?" into a race against a stopwatch — one panelist wanted the
deferral cut entirely on those grounds. A closed region now keeps a quiet
marker until it is opened, whenever that is; opening replays the rows and
clears it. That also covers the case a timer never could: a change that
lands while the reader is scrolled somewhere else is not lost, because
the marker is still there when they arrive.
Pending rows are keyed by region rather than stashed on the DOM node,
since LiveView can replace the node in between; the marker attribute is
declared client-owned so morphdom can't drop it on the next keystroke.
Also renamed. "Flash" collides with Phoenix's flash messages and names an
animation rather than a capability. A closed-region change now also writes
to a polite live region — the caller's own words, coalesced, never
assertive, never repeated on replay — because a pulse tells a
screen-reader user nothing.
Still refuses to scroll, open anything, or move focus.
… looking
Two things it got wrong.
THE MARK ACCUMULATED INTO A LIE. It said "an event happened", so flipping
between presets eventually decorated every section even when the state was
back where it started and nothing differed. It now means "there is
something here you haven't seen": the caller diffs against a per-region
baseline, ChangeCue.clear/2 removes marks whose difference is gone, and a
cued region reports itself with ChangeCue.seen_event() when the reader
opens it, which is what resets that baseline. A round trip through the
presets now ends with no marks at all.
OPENING A LONG SECTION PLAYED THE HIGHLIGHT OFF SCREEN. The reader clicks,
sees nothing, and the cue has cost them a click to tell them less than
nothing. The replay now highlights what is actually in the viewport and
watches the rest with an IntersectionObserver, so a row below the fold
lights up when they scroll to it. Observers disconnect after thirty
seconds — a section left open and never scrolled shouldn't hold them for
the life of the page.
Also: a target can now be sent grouped by the region that contains it, for
the case that started this — a row that DISAPPEARS can't be found, and
neither can the region around it, so an id alone cued nothing exactly when
something visibly vanished.
Typing @ anywhere someone can write free text offers people; typing #
offers records from every installed module. The picked result stores a
self-contained token, indexes for reverse lookup, and renders resolved
per viewer.
The resolve half already existed — ResourceLinks turns (type, uuid) into
a title and a deep-link for Activity and Comments, and the module
behaviour has carried a resource_links/0 callback with zero adopters.
This adds the three missing halves: search, storage, and a renderer that
answers the permission question.
Storage is a token in the text — @[user:uuid|Alice] — not a foreign key.
Text gets copied between records, exported, kept in history, read with
JavaScript off, and a module can be uninstalled entirely; a key survives
none of that. The label is a snapshot of what the author saw, so a dead
mention still reads as a sentence, and full-text search still finds
"Alice" because "Alice" is in the column. A bare @alice or #launch is
never linked, which keeps publishing's hashtags untouched and makes an
unfinished token stay text rather than become a broken link.
Rendering is per viewer, three ways: a live title and a real link if they
may open it; the author's stored label, unlinked, if it's gone; and a
redacted chip if it exists and isn't theirs to see. That last one shows
that something is there without showing what — the Discord model — and
clicking it asks the owners for access, because "you can't see this" is
only useful with a next step. A refreshed title is never shown to
someone who cannot open the record.
Search fans out across modules in parallel behind a 250ms deadline: a
module that is slow, raises, or has no index costs its own results and
nothing else. Each module scopes to the searcher itself — a typeahead
that offers records the searcher can't open has already leaked them.
Visibility is asked separately, batched, because the searcher and the
later reader are different people.
Pings go out on the durable save only, diffed against what was already
there, so editing text doesn't re-notify. Self-mentions are dropped, and
nobody is told about a record they can't open. Delivery reuses the
activity->notification bridge, so a ping obeys existing preferences and
lands in Email/Telegram with no new plumbing.
The typeahead is one hook attachable to any textarea (`mentions` on
<.textarea>) because the real surface is ~70 plain fields, not the three
rich editors. With JavaScript off the field behaves exactly as before —
what the hook inserts is text the field could always have held.
V163 adds the reverse index and the access-request queue. Neither target
carries a foreign key: both point into ~28 optional packages' tables.
Five real problems, the first found by auditing the registry myself and
the rest by an external adversarial pass.
Visibility failed OPEN. A type whose handler declared no
visible_resource_uuids/2 had all its uuids treated as visible, and six of
the eight registered types declare none — posts, files, CRM contacts and
companies, integrations, users. A token can be TYPED BY HAND into any
textarea, so "this module didn't implement the check" was rendering live
titles and working deep-links to any reader handed a uuid. It now fails
closed: a type is mentionable only if it answers the visibility question.
The single exception is `user`, because the @ typeahead already lists
every pingable account to anyone in the admin area, so gating names
protects nothing and would break every ping. A type nobody registered
still renders as the author's words — nothing can resolve it, so there is
no title to leak.
The typeahead lost the admin override. search_reply passed only a user
uuid, and a handler asking Authz.admin_all? gets an answer only from a
scope — so a site admin silently got the membership-only list and every
project they didn't personally belong to vanished from their own picker.
The client built tokens by string concatenation, which produced something
unparseable whenever a record's own name contained a `|` or a `]`. A
project called "Q3 | Launch" stored a broken token that no renderer would
link and no index would record. The server now builds and validates the
token, falling back to a sanitised label rather than dropping the result.
Delivery was a stamp, not a claim. Two saves of the same field racing —
a double submit, two tabs — both saw an unnotified row and both sent.
It now claims with WHERE notified_at IS NULL and only delivers what it won.
XSS is covered by tests rather than by assumption: labels reach HEEx from
attacker-controlled text, and the escaping is now pinned both ways.
The render path redacts a mention whose target the reader can't see. The
notify path did not: it attached a 140-character preview of the source
text to every ping, gated by a check that returned "yes" whenever the
source type had no handler — which is every type except projects.
So a comment on a private CRM contact reading "Acme — layoffs planned, cc
@b" showed B a locked chip on screen and emailed them the sentence. The
notify path was quietly overruling the render path, at the one point
where content actually leaves the system.
The check is three-valued now. An affirmative answer from the source
type's own handler carries the preview. "Nothing can answer" — no
handler, or a handler with no visibility check — still DELIVERS, because
failing closed would drop every ping in every module that hasn't adopted
the contract yet, and a notification whose link 403s is a dead end rather
than a disclosure. What it no longer does is quote the text.
Also pins the token-forgery case the same review raised: a record named
`x] @[user:<ceo>|see this` would, under client-side concatenation, insert
two valid tokens and ping the CEO as whoever picked it. Server-side
construction already refuses those characters in a label; the test says so.
The site switch lands in Settings > Content Editor, next to the editor
mode it sits beside conceptually. On by default: the feature is inert
until someone types a trigger, and everything it stores degrades to plain
text when it is off.
The switch is checked at RENDER, not just in search — with mentions off a
field is an ordinary textarea again, with no hook and no hint promising a
feature that would silently return nothing.
<.textarea mentions> and <.translatable_field mentions> are now the whole
opt-in for a field, which is what makes the ~70 plain textareas reachable.
translatable_field only honors it for type="textarea": a typeahead has
nowhere to go in a single-line input.
Also adds Mentions.to_markdown/2 for surfaces that render markdown rather
than HEEx. One honest limitation: markdown output is sanitised afterwards,
so a redacted mention there is plain text rather than a request-access
button — the reader still learns something exists and isn't theirs.
A mention blanked to "No access" leaves a hole where a noun should be:
the sentence stops naming the thing it is about. It now reads as the
record it names, with a lock icon and no link — knowing what something is
called is not the same as being let in — and clicking still asks the
owners for access.
The title is LIVE, resolved per render like any other mention, so a
renamed record reads correctly for everyone rather than freezing at
whatever it was called when someone typed it.
That is a deliberate reversal of the earlier caution, and the caution is
kept as an opt-in: `mentions_redact_titles` (Settings > Content Editor,
off by default) withholds the title from a reader who cannot open the
record, falling back to the label the author stored. Worth turning on
where a record's NAME is the sensitive part; unnecessary noise where the
access is.
The linked record's type and id were already stored both ways — inside
the token, and as target_type/target_uuid on the mentions row, which is
what "what links here" reads.
`ImageProcessor.sanitize/3` decodes an image and writes a fresh one, so
what gets stored is this encoder's output rather than the uploader's
bytes. That is the difference between accepting a file from a stranger
and hosting one for them.
Inspecting uploads is a losing game — a polyglot is a valid GIF and valid
JavaScript at the same time, and every content-type check has a bypass.
Decoding to pixels and writing the image out again ends the argument: an
appended script tag, an EXIF payload, a trailing archive and an embedded
colour profile all cease to exist. A test proves exactly that, on a real
crafted polyglot.
Also: `[0]` so a multi-frame GIF can't write N files, `-strip` so a
screenshot's GPS coordinates don't travel with a bug report, and a
max-edge cap because a 30000x30000 PNG is a decompression bomb whatever
its byte size.
Fails CLOSED. Anything it cannot read as an image is refused whatever the
filename claimed, and a missing ImageMagick binary returns an error rather
than raising or — the dangerous option — quietly copying the original
through. A test pins that, because the value of this function is entirely
in what it refuses.
The upload component wrapped itself in a <form> unconditionally, so any
host embedding it got nested forms — invalid HTML that the browser
resolves by closing the OUTER form at the inner tag. Every field after
that point, including the host's submit button and its anti-spam field,
silently fell outside the form. Nothing looked wrong. A `standalone`
attr (default true, so existing callers are unchanged) now omits the
wrapper, and the drop zone is extracted so both paths share it.
sanitize/3 read the image dimensions and used them only for a log line.
-resize bounds the OUTPUT; the decoder still rasterizes the input in
full first, so a small PNG declaring 50000x50000 is ~10GB of RAM before
a pixel is written. The -limit flags turn that into a failure rather
than an outage; refusing on the header costs nothing and never starts
it. Capped at 40MP.
file_uuids is a JSONB array rather than an FK column, so the reference is
invisible to the joins orphaned_files_query does — and what it cannot see,
it hands to DeleteOrphanedFileJob. Every published board image looked
unreferenced. Nothing sweeps today, so this was latent rather than active,
which is precisely the kind that surfaces as unexplained data loss later.
Verified end-to-end in the projects suite rather than by inspection: a
hand-written containment fragment over JSONB is exactly the sort that
silently matches nothing (or everything), and both directions are asserted.
`mentions_redact_titles` is off by default, and that is the right call for
internal readers — a record's name is rarely the secret. It says nothing
about the open web, and both audiences share one renderer. So a public page
can now ask for withholding on its own account: a mention outside what the
page may show resolves to `:private`, with no title AND no fallback to the
author's stored label (the label is the record's name as of writing, so
falling back to it publishes exactly what withholding was for).
Two holes found by an external panel and confirmed here:
`to_markdown/2` had no `:private` clause, so the catch-all emitted
`token.label` — passing the flag into a markdown render was a silent no-op,
and comment bodies sit right next to a description that withheld properly.
`user` is a public type, so a user mention is always allowed, and its
resolved path is `/admin/users/view/:uuid`. Linking that from a public page
hands an anonymous reader an admin route and a user uuid, and quietly reads
"listed inside the admin app" as "public on the open web". On a withheld
surface a person is a name, not a door.
`CommentResources` is the registered ResourceLinks handler for the "user"
type, so it names every @ mention there is — public issue boards included —
and it ended `|| user.email`. A commenter who had never set a name had
their full address rendered next to their words on an indexable page.
Proven before the fix:
%{state: :ok, title: "nameless-5954@example.com"}
`User.display_name/1` is now the single answer: organisation name, then
first+last, then username, then the local part of the email, then "User".
Never the address. Every rung is trimmed and rejected when blank, because
`full_name/1` returns "" (not nil) for a first name of " " and an
unguarded chain stops dead on it.
Only fields the USER wrote about themselves feed it. A name recorded by
somebody else — the staff module's HR record, a CRM contact label — must
never reach it. The people with no name set are precisely the ones who are
pseudonymous by choice or inertia, and publishing an HR-entered legal name
on their behalf is not ours to do. That, rather than a default-on setting,
is what dissolves the consent question.
Collapsed into it: Mentions.Users, CommentResources, media_browser's
creator chip. The mention typeahead also stopped shipping the full address
as a result subtitle, which gave back exactly what the title was protecting.
The local part is not anonymity — john.smith@ still reads as john.smith —
but it carries no domain and is not mailable. Most accounts have an
auto-generated username anyway, so that rung usually wins.
V164 freezes comment attribution: what the reader was shown, and whether
the author was speaking for a project. Resolving either at render rewrites
history — someone leaves or fills in a profile and every comment they made
is silently re-signed. Existing rows stay NULL and resolve live rather than
being backfilled with a history we do not have.
ddon pushed a commit that referenced this pull request Aug 9, 2026
Post-merge review of #692 (cross-module mentions, canonical display name,
no published email addresses). The subsystem's security thinking is sound —
visibility fails closed, titles re-resolve per viewer, notify refuses a
preview it can't prove is readable. Five defects in the details.
- V165's up/1 stamped the schema version as '163'. Every other migration in
the chain stamps its own number, so an install migrated to exactly 165
reported 163 — behind where it started — and the next ensure_current/2
re-ran V164 and V165. A full run to head hid it because V166 stamps
immediately after. Leftovers from the V163/V164 draft numbering also fixed:
V166's moduledoc reference and V165's dead `schema` binding.
- escape_md/1 left `]` unescaped on the reasoning that the token grammar
refuses it. True of the author's stored label; false of the live resolved
title, which is the record's current name. Renaming a record to
`Evil](https://evil.example)` turned every markdown-rendered mention of it
into a link to the attacker's URL. Verified both the break and the fix
against MDEx, the renderer this project actually uses.
- MentionText's moduledoc told hosts to `use PhoenixKit.Mentions.RequestAccess`,
which does not exist — the module is PhoenixKit.Mentions.Live, and it needs
<.access_request_dialog> rendered or the click sets an assign nothing reads.
- ImageProcessor.sanitize/3 has no callers; documented as a primitive rather
than protection now in place.
Reported and deliberately not applied: the @ typeahead runs ~130 queries per
keystroke (Scope.for_user/1 per candidate, over-fetching 64), and the fix is
an authorization filter rewritten as SQL — not a change to make without
executing the suite. Also unfixed: context/2's batching is unavailable to
<.mention_text>, which calls it per component.
Review: dev_docs/pull_requests/2026/692-add-mentions-display-name-no-email/CLAUDE_REVIEW.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mdon added a commit to mdon/phoenix_kit_ecommerce that referenced this pull request Aug 9, 2026
PhoenixKitEcommerce.Slugify existed because the rule had drifted between Product
and Category twice — Cyrillic, then German — and each fix reached only one of
them. It is deleted: there is now one implementation for the whole ecosystem, in
the locale_slug package, reached through PhoenixKit.Utils.Slug. A third drift is
not reachable because there is no second copy to drift from.
The bigger fix is that both schemas were DISCARDING the language:
Enum.reduce(title_map, slug_map, fn {lang, title}, acc ->
generated = slugify(title) # lang bound, unused
so every language got the neutral rule. Slug generation is locale-dependent —
German ö expands to oe, Estonian ö folds to o — and passing `lang` is the whole
point of a per-language slug map. AITranslatable had the same shape: it had
target_lang in scope and called Product.slugify/1 without it.
BEHAVIOUR CHANGE worth reviewing: the old module expanded German umlauts
UNCONDITIONALLY, for every language. That was the same bug the `slugger` package
has — correct for German, wrong for Estonian. Expansion is now conditional on
the language:
"Größe", lang "de" -> "groesse" (unchanged)
"Größe", lang "et" -> "grosse" (was "groesse" — wrong before)
"Größe", no lang -> "grosse" (was "groesse")
Stored slugs are NOT rewritten — the changesets only generate when the slug is
blank — so live URLs are untouched. Only newly generated slugs differ, and both
schemas carry a unique constraint for the collision case.
Tests updated to pin the per-language contract rather than the old
expand-for-everyone behaviour.
Requires the phoenix_kit release carrying the locale-aware Slug
(BeamLabEU/phoenix_kit#692). Verified against local core: 454 tests, 0 failures.
mdon added a commit to mdon/phoenix_kit_posts that referenced this pull request Aug 9, 2026
Five sites (post, post_tag, post_group schemas + the post and group edit
LiveViews) each had their own:
|> String.downcase()
|> String.replace(~r/[^\w\s-]/, "")
|> String.replace(~r/\s+/, "-")
|> String.trim("-")
That deletes every non-ASCII character, so a Cyrillic or Greek title produced
an EMPTY slug and German lost its umlauts and ß:
"Видеопродакшн" -> "" now "videoprodakshn"
"Καλημέρα" -> "" now "kalimera"
"Größe Fußball" -> "gre-fuball" now "grosse-fussball"
An empty slug is worse than a wrong one: callers read it as "no slug yet" and
regenerate on every save.
Now delegates to PhoenixKit.Utils.Slug, which is backed by the locale_slug
package — one implementation for the whole ecosystem rather than five copies
that can drift apart.
Stored slugs are NOT rewritten; the changesets only generate when the slug is
blank, so existing URLs are untouched.
Requires the phoenix_kit release carrying the locale-aware Slug (currently in
BeamLabEU/phoenix_kit#692). Verified locally against that core: 22 tests, 0
failures.
mdon added a commit to mdon/phoenix_kit_newsletters that referenced this pull request Aug 9, 2026
list.ex stripped every non-ASCII character, so a Cyrillic or Greek list name
produced an EMPTY slug. Now delegates to PhoenixKit.Utils.Slug (backed by
locale_slug).
Requires the phoenix_kit release carrying the locale-aware Slug
(BeamLabEU/phoenix_kit#692).
mdon added a commit to mdon/phoenix_kit_publishing that referenced this pull request Aug 9, 2026
publishing_group.ex derived its slug with an ASCII-only pipeline, so a Cyrillic
or Greek group name produced an EMPTY slug. Now delegates to
PhoenixKit.Utils.Slug (backed by locale_slug).
SlugHelpers already delegated to core, so this closes the one place that did
not.
Stored slugs are not rewritten. Requires the phoenix_kit release carrying the
locale-aware Slug (BeamLabEU/phoenix_kit#692). Verified against local core:
1563 tests, 0 failures.
mdon added a commit to mdon/phoenix_kit_dashboards that referenced this pull request Aug 9, 2026
dashboard.ex stripped every non-ASCII character, so a Cyrillic or Greek
dashboard title fell through to the "dashboard" fallback instead of getting a
real slug. Now delegates to PhoenixKit.Utils.Slug (backed by locale_slug); the
"dashboard" fallback is kept for genuinely content-less titles.
Requires the phoenix_kit release carrying the locale-aware Slug
(BeamLabEU/phoenix_kit#692). Verified against local core: 239 tests, 2
failures, both PRE-EXISTING and unrelated (widget grid placement / legacy
string spans in dashboards_test.exs — same 2 fail with this change stashed).
mdon added a commit to mdon/phoenix_kit_document_creator that referenced this pull request Aug 9, 2026
template.ex stripped every non-ASCII character, so a Cyrillic or Greek template
name produced an EMPTY slug. Now delegates to PhoenixKit.Utils.Slug (backed by
locale_slug).
Requires the phoenix_kit release carrying the locale-aware Slug
(BeamLabEU/phoenix_kit#692).
mdon added a commit to mdon/phoenix_kit_projects that referenced this pull request Aug 9, 2026
project_status.ex stripped every non-ASCII character, so a Cyrillic or Greek
status name produced an EMPTY slug — and statuses are looked up BY slug
(Statuses.current_status/1 treats "" as nil), so the status silently vanished.
Now delegates to PhoenixKit.Utils.Slug (backed by locale_slug).
Requires the phoenix_kit release carrying the locale-aware Slug
(BeamLabEU/phoenix_kit#692).
⚠️ This repo does not currently compile against local core for a reason
unrelated to this change: project_form_live.ex and template_form_live.ex call
`ai_multilang_tabs/1`, which needs a newer phoenix_kit_ai than is resolved. The
same failure occurs with this change stashed.
ddon pushed a commit that referenced this pull request Aug 9, 2026
Prepared, NOT published: `mix phoenix_kit.release_check` blocks on a stale
ExpectedSchema manifest. See the release note in the #692 review — V165 adds
two tables and V166 four columns, and none of them are declared in
ExpectedSchema.objects/1, so restamping chain_hash is the wrong tool by that
script's own rules. Regenerating needs a pre-squash checkout and a database.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request Aug 9, 2026
#692 added V165 and V166 without regenerating ExpectedSchema, so chain_hash is
stale and release_check refuses to publish. Recorded with the reason the
DB-free restamp script is the wrong tool here — V165 creates two tables and
V166 adds four columns, none of which the manifest declares — and the two ways
forward.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request Aug 9, 2026
dev_docs/squash/README.md says outright that a migration PR may land with a
stale manifest and that regeneration is the release-time gate, so filing this
as a BUG against #692 was wrong. Reclassified as a release blocker owned by
whoever publishes.
Also records the gap the phrase "just regenerate" hides: the runbook requires
regenerating from a PRE-SQUASH checkout, whose chain ends at V163, so no
generator run can ever emit V164/V165/V166 objects — which is why V164's are
hand-written DECLARED POST-GENERATION CORRECTIONs.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
mdon added a commit to mdon/phoenix_kit_posts that referenced this pull request Aug 9, 2026
Five sites (post, post_tag, post_group schemas + the post and group edit
LiveViews) each had their own:
|> String.downcase()
|> String.replace(~r/[^\w\s-]/, "")
|> String.replace(~r/\s+/, "-")
|> String.trim("-")
That deletes every non-ASCII character, so a Cyrillic or Greek title produced
an EMPTY slug and German lost its umlauts and ß:
"Видеопродакшн" -> "" now "videoprodakshn"
"Καλημέρα" -> "" now "kalimera"
"Größe Fußball" -> "gre-fuball" now "grosse-fussball"
An empty slug is worse than a wrong one: callers read it as "no slug yet" and
regenerate on every save.
Now delegates to PhoenixKit.Utils.Slug, which is backed by the locale_slug
package — one implementation for the whole ecosystem rather than five copies
that can drift apart.
Stored slugs are NOT rewritten; the changesets only generate when the slug is
blank, so existing URLs are untouched.
Requires the phoenix_kit release carrying the locale-aware Slug (currently in
BeamLabEU/phoenix_kit#692). Verified locally against that core: 22 tests, 0
failures.
mdon added a commit to mdon/phoenix_kit_projects that referenced this pull request Aug 9, 2026
project_status.ex stripped every non-ASCII character, so a Cyrillic or Greek
status name produced an EMPTY slug — and statuses are looked up BY slug
(Statuses.current_status/1 treats "" as nil), so the status silently vanished.
Now delegates to PhoenixKit.Utils.Slug (backed by locale_slug).
Requires the phoenix_kit release carrying the locale-aware Slug
(BeamLabEU/phoenix_kit#692).
⚠️ This repo does not currently compile against local core for a reason
unrelated to this change: project_form_live.ex and template_form_live.ex call
`ai_multilang_tabs/1`, which needs a newer phoenix_kit_ai than is resolved. The
same failure occurs with this change stashed.
ddon pushed a commit that referenced this pull request Aug 9, 2026
Not my work — Pincer's pre-merge surface review of #692, left untracked in the
working tree. Committing it alongside CLAUDE_REVIEW.md in the same directory so
both halves of the review are on record, and so the release gate's clean-tree
check passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request Aug 9, 2026
Post-merge review of #694. The headline fix is real and the PR self-corrected
two significant defects before merge (the first commit fixed the wrong twin;
the new atom crashed status through a clause the author had not extended) —
both documented plainly in c661f1b. Two gaps left, one of them red on main.
- StatusReport's `action()` type never gained
`{:fix_version_comment, String.t()}`. next_action/3 is @SPEC'd to return
`action()`, so dialyzer proved the new
`format_next_action({:fix_version_comment, _})` clause unreachable and exited
2. `mix precommit` therefore fails on main as merged; every later PR inherits
a red gate. The PR's stated verification covers compile, format and credo —
three of the four steps precommit runs, and dialyzer is the one that catches
this.
- The @doc return lists for check_installation_status/1 and
check_update_needed/2 both still enumerated the old states. That list is the
contract callers read to decide which clauses to write, and this PR already
paid for it being wrong once.
Verified rather than assumed: all three consumers do handle the new state;
repair_uuid does check castability, so V163's reordered deferral is not a dead
end; the three version-comment parsers agree except on non-positive integers,
where the new one is the stricter; 86 tests + 6 doctests pass. The
mentions/users.ex dialyzer ignore #692 added is load-bearing — removing it goes
red.
Note for the release: #694's v163/v164 edits are guard-ordering and detection
shape only, so they stay restamp-safe. V165/V166 remain the only versions that
add manifest objects.
Review: dev_docs/pull_requests/2026/694-stop-reporting-comment-less-database-as-version-1/CLAUDE_REVIEW.md
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request Aug 9, 2026
I created a second directory for #694 without checking for an existing one.
Moved CLAUDE_REVIEW.md alongside Pincer's phase1.md, which was there first,
matching how #692's two halves sit together.
phase1.md is not my work — committing it so both halves are on record and the
tree stays clean for the release gate.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request Aug 9, 2026
Both clean — no new defects. Verified the parts this series has repeatedly got
wrong rather than the parts the diffs touch:
#695 introduces two new return values and both propagate completely.
estimated_rows/3's :unknown is handled by an explicit pattern match in V163
(not by term ordering, which would have made it correct by accident) and named
out loud by repair_uuid. Probe's :unparseable reaches classify/3, Repair.run,
error_message/1, the error type, and the JSON error_tag — traced each. The '0'
behaviour change it implies is unreachable: nothing stamps IS '0'.
#696's "two siblings" claim is exhaustive: no `_system ->` left in auth.ex, and
delete_user was already fail-closed by a different mechanism
(validate_can_delete_user/2's fallback refuses even nil).
It also corrects a test I added in the #692 pass, and the correction is worse
than its commit message claims. My version poisoned all five identity fields in
one payload; "attacker-user_timezone" fails validate_user_timezone/1, one
invalid member invalidates the whole profile_changeset, so nothing was written
either way. It passed with the filter DELETED and failed with the filter
working — inverted, not merely vacuous, and red on main since I added it. The
exact defect I spent three reviews naming in other people's tests, in a test I
could not run. Written up in full in the #696 review.
CHANGELOG deliberately NOT committed: another agent is mid-edit in this tree
(implementing the mentions N+1 and access-request findings from my #692 review,
and hand-declaring V165/V166 in the manifest), and CHANGELOG.md now holds their
in-flight entries alongside mine. Committing it would separate their notes from
their code.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request Aug 10, 2026
…6 manifest
Not my work — another agent's changes, uncommitted in the working tree while I
was reviewing #695/#696/#697. Committing at the maintainer's request so nothing
is stranded. Verified it compiles with warnings-as-errors before committing.
Three pieces, all closing findings from my earlier review passes:
- Mentions.Users.search/2 no longer calls Scope.for_user/1 per candidate; the
admin-area rule became two SQL EXISTS subqueries with the limit applied in the
database. This is the ~128-queries-per-keystroke item I reported in the #692
review and deliberately did not fix, because rewriting an authorization filter
as hand-written SQL without a database to test against was not a change to
make blind. It has a database now.
- AccessRequests.request/4 validates the resource type against
ResourceLinks.handlers/0, requires a castable uuid, and rate-limits per
account — the unvalidated/unthrottled path I filed as a nitpick on the same PR.
- ExpectedSchema hand-declares V165/V166 objects and restamps chain_hash, which
is the release blocker. Their choice of the hand-declare route matches what the
#692 review concluded was necessary: no generator run can emit V164+ objects,
because regeneration replays a pre-squash checkout whose chain ends at V163.
CHANGELOG.md is deliberately NOT in this commit. The copy in this tree predates
the 2.0.0 retitle and the #695-#697 entries now on main, so committing it would
have reverted them. Their three entries were extracted verbatim first and are
re-applied on top of the current file in the next commit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
ddon pushed a commit that referenced this pull request Aug 10, 2026
Two loose ends from committing the other agent's work.
The chain_hash they wrote was computed before #695 landed, and #695 edited
v163.ex (cond branch order) and v164.ex (a detection query's contype filter, a
wrapped probe). So the stamp was behind by exactly those two edits while the
manifest BODY — their hand-declared V165/V166 objects — was already correct.
Restamped over the 32 shipped files, which is legitimate here for the reason the
script requires: both #695 edits are guard, probe and message logic and add no
schema object, established in the #695 review. Recorded inline, including what a
restamp still does not assert.
release_check is now 5/6 and the two unit tests that had been red on main since
#692 pass again (test/mix/tasks/phoenix_kit_release_check_test.exs, 15 tests).
CHANGELOG: their three recheck entries re-applied on top of the 2.0.0 file, since
the copy in this tree predated the retitle and would have reverted it.
⚠️ Still outstanding, and not something a green gate should be read as covering:
the V165/V166 objects in the manifest have never been checked against a real
database. `verify.exs --scenario s7,s8` is what would do that, and #689's
equivalence evidence still predates HEAD. Both need the PostgreSQL that has not
been reachable in any of these passes.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants

@mdon@ddon