Social Media — the shared base package
This package is the base of the social suite: it defines the network-agnostic types every channel package (LinkedIn, X, YouTube) builds on. It ships no personal data and no channel logic — those live in the channel packages and in the team's working spaces.
The two types
SocialMedia/Post — one post, every channel
A post is authored once. Its content carries:
| Field | Meaning |
|---|---|
text |
The post text exactly as it will appear on the network — the body is the truth. |
author / authorHeadline / authorImageUrl |
The profile it goes out under (initials disc when no image). |
scheduledAt |
The publishing slot (null = not scheduled yet). |
status |
Draft · InReview · Approved · Scheduled · Published. |
networks |
The channels it is foreseen for — ids from the hub's Channels registry, e.g. LinkedIn-Roland, LinkedIn-Robert, LinkedIn-Systemorph, X, YouTube. |
publishedUrl |
Once live: the URL on the network. |
The post's page (Preview, the default area) renders the 1:1 LinkedIn appearance — avatar, name, headline, schedule line, full text, action bar, network chips — followed by the fold previews: what readers see above the "…see more" cut on desktop (~210 chars) and mobile (~140 chars). No separate preview copies to maintain: the previews are computed from the text.
SocialMedia/PostsHub — the publishing hub root
The root type of a team's publishing space (on this mesh: Posts). Its front page
(Timeline, the default area) weaves the upcoming posts together with every Marketing/Event
and running Marketing/Campaign on the mesh — one date-grouped plan: per day the events first,
then the campaigns, then the posts. The marketing items render THEMSELVES through their own card
areas (EventCard / CampaignCard); this hub only orders them, reading their dates
shape-tolerantly, so a mesh without the Marketing package simply shows the plain feed.
Next to it, Posts is the posts-only LinkedIn-style feed — published posts drop out, the next
slot is on top, undated drafts close the list. Each card is 1:1 the feed anatomy, cut at the
desktop fold, links its post page, and shows the target networks as chips. The hub content's
body markdown (process notes, channel links, the contents catalog) renders below either feed.
The timeline is a live query stream accumulated the Store-catalog way — never a blocking query inside a render (the platform's old SocialMedia/Post demo deadlocked three meshes exactly that way).
How the channel packages use it
LinkedIn, X and YouTube declare requires: ["SocialMedia", "Store"] — the dependency edge
is in place; migrating their calendars and archive imports (today's PostItem / XPostItem /
YtVideoItem models) onto the shared SocialMedia/Post type is the planned next step, so planned
posts (in the hub) and published posts (in the channel spaces) become one vocabulary. The network
ids in networks match the hub's Channels registry pages.
Tests
Both types ship executed test suites (Post/Test, PostsHub/Test) and expose them as Tests
areas — run in CI by the plugin gate, not merely compiled.
The /post skill carries the writing standard — and its installed COPIES do not follow (2026-09-12)
Posts/WritingStandard is the house craft standard for every post in this space. It is a mesh
node and does not ship in this repo, so a skill or agent that depends on it must both POINT at it
and INLINE its absolute rules — pointing alone leaves a fresh install with no standard at all.
The Post Writer agent (LinkedIn/Agent/post-writer) has done both since 28 August. The /post
skill did neither, and the surface most people actually use is the skill: the default Assistant, in a
thread on a post node, loads /post, not the agent. So every edit made that way was written to no
craft standard (#167). Measured: Posts/SavThankYou v1 → v2, asked only to be more precise about the
participants, came back with a 4-sentence ~320-character opening paragraph carrying an em dash and a
paragraph break through the hook — three hard-rule violations in one unrequested rewrite.
Two things the fix had to do beyond quoting the page:
- Say EDIT, not only draft. The gap was never in drafting; it was in a correction turning into a rewrite. The skill now requires the smallest change that fixes what was asked, forbids restructuring or expanding the hook to make room, and requires a reply in the thread when the requested precision cannot fit the shape.
- Repair the skill's own contradicting advice. It said the hook was "line 1–2" — which permits the very split the standard forbids, because a paragraph break ends the visible part everywhere. It now says one unbroken paragraph and gives the number (210 characters, first 140 standing alone).
🚨 What the Assistant actually loads — and why an installed copy is NOT it
This needed checking rather than assuming, and the assumption was wrong.
Measured on memex.meshweaver.cloud, 2026-09-12:
| node | nodeType | version | last written |
|---|---|---|---|
SocialMedia/Skill/post — the master, GitSynced from this repo |
Skill |
6 | 2026-08-27 |
rbuergi/Skill/post — an "installed copy" |
Markdown |
1 | 2026-08-14, by system-security, never touched since |
Read in the AI engine (MeshWeaver.AI, MeshWeaver.Plugins): skills are resolved through
AgentPickerProjection.BuildSkillQueries, whose per-user layer is
namespace:{user}/Skill nodeType:Skill — and SkillNodeType.ProjectSkills then skips any node
whose NodeType is not Skill:
if (!string.Equals(node.NodeType, NodeType, StringComparison.OrdinalIgnoreCase)) continue;
So a copy typed Markdown is inert as a skill, twice over: the query does not return it and the
projection would drop it anyway. /post resolves from the node-type partition layer
(path:SocialMedia scope:descendants nodeType:Skill) — i.e. the master. Editing this repo
therefore reaches the Assistant on the next GitSync, and nothing has to be refreshed for the fix to
take effect.
That falsifies the mechanism #167 states ("the Assistant loads the /post skill — per-user
installed copies"). The bypass was real and the incident was real; the route was not. Worth writing
down, because the wrong route would have sent the next reader to refresh copies instead of editing
the module.
What the stale copy IS. A Markdown node at {user}/Skill/post that a person can open and read
as a page — and it is four weeks behind on more than the writing standard: it still says "the mesh
does not auto-publish, so publish on the network first", untrue since the scheduler shipped. That is
a documentation node telling someone the wrong thing, not a skill overriding anything. Refreshing or
removing it is a separate, deliberate write per partition.
🚨 And if you ever sweep for them, the obvious sweep is wrong. search nodeType:Skill returns the
master ALONE and reads as "no stale copies exist", because the copies are typed Markdown. Use
name:post scope:subtree. Then state the denominator: that search runs as YOU and is RLS-filtered,
so copies in partitions you hold no grant on are silently not counted, never reported as an error.
On this mesh, as this reader, on this date: 1 copy over the partitions I can read — not "1 copy
on the mesh".
Query discipline — every read is anchored, the feed is paged (2026-09-02)
The rule. A mesh query whose path:/namespace: first segment is concrete is served from
ONE Postgres schema. A query without one (nodeType:SocialMedia/Post limit:2000) is a
UNION ALL over every partition schema of the mesh — 199 on memex-cloud — and, beyond its own
cost, a lock storm that slows every other query on the database
(Doc/Architecture/CrossSchemaFanOutElimination). Nothing in this package may issue one.
What was measured before the fix (memex-cloud, 2026-09-02 19:20–19:21Z, opening the four
pages of Posts once each; Loki [CrossSchema] SLOW, 1000-line cap reached in 60 s):
| Query shape | Count | Per query | Schemas |
|---|---|---|---|
nodeType:SocialMedia/Post path:- scope:Exact |
21 | 1.4–8.0 s | 104 of 199 |
nodeType:SocialMedia/PostTarget path:- scope:Exact |
21 | 1.4–10.1 s | 104 of 199 |
nodeType:SocialMedia/Profile path:- scope:Exact |
17 | 1.4–10.1 s | 104 of 199 |
nodeType:Marketing/Event / Marketing/Campaign (Timeline) |
6 / 5 | 1.6–10.2 s | 104 of 199 |
The Posts feed answered after ~18 s, Dashboard after ~20 s, Timeline and Past timed
out (60 s). The same minute also carried the platform's own unanchored reads — Notification
(~4 400 rows, ×112), AccessAssignment/GroupMembership (the security fold), UiContribution,
Agent, Thread, User — which this package cannot fix; they are the census in core's
CrossSchemaFanOutElimination page.
Where the sources were. PostResolution.ObserveProfiles and ObserveTargets read
nodeType:SocialMedia/Profile / nodeType:SocialMedia/PostTarget mesh-wide;
PostsHubLayoutAreas.ObservePosts read nodeType:SocialMedia/Post mesh-wide and filtered by
path prefix afterwards; ObserveByType read Marketing/Event and Marketing/Campaign
mesh-wide; the post editor's author picker and add-target picker offered
nodeType:SocialMedia/Profile. Each was multiplied by the framework's live re-query on change.
What anchors to what now.
| Read | Anchor |
|---|---|
| Posts of a hub | namespace:{hub} scope:descendants nodeType:SocialMedia/Post — posts are children of the hub |
| Targets of a hub / a post | namespace:{hub-or-post} scope:descendants nodeType:SocialMedia/PostTarget — targets are {post}/Target/* |
| Profiles | one namespace:{partition} scope:descendants nodeType:SocialMedia/Profile per partition the page's posts and targets REFERENCE (PostResolution.ReferencedProfilePaths → ProfilePartitions); with every profile under Profiles/ that is one read |
| Profile pickers | namespace:Profiles … plus namespace:{post's partition} … (PickerPartitions) — the two places a profile lives by convention |
| Events / campaigns | namespace:Event … / namespace:Campaign … — Marketing ships types only; instances live in those partitions (its root node says so) |
The feed is paged. Posts reads one ordered page — sort:lastModified-desc, Skip = page × 50,
Limit = 51 — the 51st row being how the page knows an older one exists without a count query;
the page rides the address (…/Posts?page=2, …/Posts?author=Profiles/X&page=2). Core #1135
made Skip/Limit a partition of the result over a total order (sort key, then path), and the
page applies the same order client-side so a live update lands where the server would put it.
Dashboard, Timeline and Past still read the hub's whole post set — they bucket and order it — but
anchored and once.
The guardrail. PostsHubLayoutAreas.AllQueryShapes is the census of every read the pages
perform; PostsHubTests.Queries_EveryShapeIsAnchoredToOnePartition holds each one (and the
picker queries) to PostResolution.IsAnchored, and a second test holds the predicate itself to
the fan-out shapes it must reject. A new read belongs in the census, or the Tests area fails.
The SCHEDULER reads too — the other half, anchored 2026-09-07
The 2026-09-02 work above anchored everything the pages read. It did not touch the one reader
that is not a page: ScheduledPostWatcher, the hosted service in the compiled
MeshWeaver.Social module that turns a post's scheduledAt into an armed publish timer. It ran
the last unanchored read this suite owned —
nodeType:*Post select:path,id,namespace,name,nodeType,content,lastModifiedBy
— reported from production by PostgreSqlPartitionedMeshQuery as census row 4, the
"suffix/wildcard nodeType" caller that had been measured live but never named
(Systemorph/MeshWeaver#3545; three occurrences across three pods, 2026-09-07 06:21–06:30Z). The
host runs UnanchoredQueryPolicy.ServeAndReport, so it did not merely log — it served the
query, as one UNION ALL over every partition schema.
Was the fan-out real? Measured on memex-cloud, 2026-09-07:
Registered partitions (Admin/Partition) |
105 |
Nodes matching nodeType:*Post mesh-wide |
31 |
…in Posts (namespace:Posts scope:descendants nodeType:*Post) |
30 |
| …elsewhere | 1 — Doc/DataMesh/SocialMedia/Post/Post-001, a documentation sample |
SocialMedia/PostsHub nodes on the mesh |
1 — Posts |
So the read was never mesh-wide in substance: it locked ~500 relations to collect rows that all
live in one schema, and the single node the anchoring drops is a doc example. That example is
worth naming — its nodeType ends in Post and its content is a SocialMediaPost, so it was in
the watcher's candidate set. It is Draft today; a doc edit setting status: Scheduled on it
would have armed a real LinkedIn publish for a documentation page. Anchoring removes that too.
The route, and the one deliberately not taken. The reporter offers two: anchor the query, or
declare the fan-out with partitions:all. 🚨 partitions:all was rejected. It lowers the log
line and changes nothing else — the same UNION ALL over the same ~500 relations still executes,
and that is the measured mechanism of the 2026-08-31 portal outage. Declaring it would have been
choosing a documented cost and calling it a fix. Adding a line to
unanchored-queries.allow is worse still (shrink-only, and it only demotes Error to Warning).
With 30 of 31 rows in one partition, the fan-out is reducible, so it was reduced.
What anchors to what now.
| Read | Anchor |
|---|---|
| Candidate posts | one namespace:{hub} scope:descendants nodeType:*Post … per publishing partition (ScheduledPostWatcher.PostsQuery) — the framework unions them under one synced-query registry entry |
| Armed timers | path:Admin/EventSubscription scope:children nodeType:EventSubscription … — already anchored |
The nodeType:*Post wildcard stays: it is what keeps the watcher's predicate identical to
SocialPostMenuProvider's, and a suffix match is the one way to match a type whose id changes with
its install location. The wildcard was never the problem — the missing partition anchor was.
Where the hubs come from. Social:Scheduling:Hubs (SocialSchedulingOptions), defaulting to
the conventional Posts partition — the same shape PostResolution.ProfilesPartition and
ProfileTilesEnsure.ProfilesPartition already use for profiles. A deployment authoring posts
elsewhere lists its hubs there, and the watcher names the partitions it watches in its startup log
line so "my post never fired" is one grep rather than an invisible silence.
🚨 The default lives on EffectiveHubs, not on Hubs, and that was measured rather than
styled. With Posts as the property's initial value, ConfigurationBinder appends to it
instead of replacing it: binding Social:Scheduling:Hubs = [TeamPosts, Marketing] produced
[Posts, TeamPosts, Marketing]. A deployment that had moved its posts off Posts would have gone
on reading a partition it does not use, quietly, with its appsettings saying otherwise. The
property starts EMPTY (so the append is the whole answer) and EffectiveHubs supplies the default
when nothing was configured. SocialSchedulingOptionsTest asserts the bound list exactly for
this reason — "not empty" would have passed on the broken shape.
🚨 Plus every partition an existing publish timer points into (WatchedPartitions). That is a
correctness requirement, not a convenience: CancelOrphanedTimers cancels a pending timer whose
post is not among the candidates, so a timer in an unread partition would be cancelled for being
invisible rather than for being unscheduled — silently un-scheduling a healthy post. Carrying
the timers' own partitions makes the two sets agree by construction, and it is what let this change
ship with no migration: every post the old mesh-wide read had armed keeps its partition read.
IsOrphaned additionally refuses to cancel on a snapshot that did not cover the timer's partition,
which holds during the one emission between a partition joining the set and its first result
arriving.
A second defect closed on the way. Issue #50 — every timer armed from a storage read named
nobody, so the handler refused each timed publish hours later with "it names no CreatedBy" — had
this same query as its root cause: the cross-schema record shape carries neither created_by nor
last_modified_by, and a select: cannot add a column the fan-out never returns. An anchored
mesh_nodes query answers NeedsFanOut = false and is served by the per-schema provider, whose
column list is n.created_by, n.last_modified_by, n.created_date. The authoritative per-node read
in ResolveScheduler stays — the cure was never "pick a query whose projection happens to
work" — but it is now the rare path rather than the normal one.
The guardrail. ScheduledPostWatcherTest.EveryCandidateQueryIsAnchoredToOnePartition holds
both of the watcher's query shapes to the same IsAnchored predicate the hub's in-mesh tests use.
It has to be a guard on the STRING: the in-memory query provider has no partitions and serves an
unanchored query perfectly happily, so no behavioural test in that suite can tell the two apart.
In-Memex pings instead of update emails (2026-09-07)
The dashboard above answers "what is the state of everything" when someone opens it. The pings answer the other half of issue #80 — "tell me without me having to look" — and they replace the update emails: "Carson never sends an update email again and nothing gets missed."
How it works. PostNotificationWatcher (in the compiled MeshWeaver.Social module) reads the
same ANCHORED per-hub post query the scheduler uses, derives the pings the mesh should be carrying,
and writes the ones that are missing.
| Code | Raised when | What makes two of them different |
|---|---|---|
awaiting-review |
the post is written and not yet approved | nothing — one per post, per reviewer |
scheduled |
the post has a slot | the SLOT, so moving the date pings again and editing the text does not |
failed |
the last publish attempt did not go through | the error, so a NEW failure pings and a repeat does not |
published |
the post is live (off by default) | the URN |
published is off by default deliberately: pinging every reviewer for every publish would rebuild
the mail volume this feature exists to remove.
Who gets pinged is DATA, and by default nobody. content.notifyRecipients on the hub node is a
list of user ids; content.notifyOn narrows the codes. An empty list means this hub pings nobody,
and the watcher says so at startup naming the hub. That is not an oversight — WHO should be told is
a product decision, and the alternative defaults are all wrong: pinging the author about their own
edit is useless, and pinging everyone is the spam the feature is replacing. The issue thread names
"Roland and Robert" as today's answer and, in the same breath, warns against hard-coding two people,
because #69 had just finished removing exactly that. So it is one edit on the hub node, no deploy.
🚨 A recipient is a USER id, not a profile path. A SocialMedia/Profile names its operator in
free text (Owner), which is not an identity anything can deliver to.
The write storm this is built not to be
A watcher that writes notifications while observing nodes is the self-triggering-reconcile shape of
core #223 — a _Policy node rewritten 257 000 times at ~14 writes/min on production, with
nothing failing and nothing logging. Part 2 of #80 sat unbuilt for a week for exactly this reason.
Three independent guards, in order of how much it costs to be wrong about them:
- The desired set reads no clock. Every ping is a pure function of stored post state, so a pass over an unchanged mesh produces an unchanged desired set. This is why overdue is deliberately not a ping: it becomes true with the passage of time rather than with a write, so it would need a poller, and a poller is the wrong answer to a missing event. The dashboard shows overdue posts, which is where it belongs until there is a real scheduled trigger.
- Read before write. The recipient's bell is read live and anchored, and a ping already there is not written. That is what makes the steady state cost nothing — no write, and no re-emission.
- Write once per identity per process, unconditionally. Even if guard 2 were wrong — if the node id this module derives ever stopped matching where the write lands — the same ping is written at most ONCE. A storm is then impossible by CONSTRUCTION rather than by the correctness of a comparison.
- And the divergence guard 3 would hide is checked exactly, at the write. The write hands back the node it created, so its path is compared against the derived one and a mismatch is an Error naming both. That is the loud non-convergence check #80's thread asked for, and it is deterministic: the alternative — wait and see whether the ping turns up in the bell — cannot tell a defect from a write that has simply not landed yet, and would cry wolf on every first pass, which is how a real warning becomes noise nobody reads. (It would have: the first draft of this watcher counted bell emissions and would have warned on every startup with more than one post.)
PostNotificationWatcherTest.ASecondPassWritesNothing is the assertion: the ping's node VERSION must
not move while the watcher keeps reconciling — with a second post's ping arriving as the control that
passes actually happened, and a deliberate write at the end as the control that the version is a live
instrument at all. Without that last step "the version did not move" would also pass on a node whose
version never moves, and would go on passing through the storm it exists to catch.
Addressed to the recipient, never to the post
The notification is written to {recipient}/_Notification/{id} (core #2640). The issue's original
plan said {mainNodePath}/_Notification/{id} — under the ENTITY — which is the shape being retired:
it left the bell unable to name a partition, and the bell was measured as the platform's largest
cross-schema fan-out (444 199-schema unions per five minutes across eight pods). The post stays a
reference on Notification.TargetNodePath, which is what the bell navigates to on click. So this
feature adds no fan-out and needs no rewrite when the read side finishes moving.
Two things this does NOT do, said plainly
- The bell text is English only. Every notification emitter in the platform is
(
AccessGrantNotifier,StartupErrorNotifier,CompileFailureNotifier, the plugin-catalog reconcilers), becauseNotificationServicehas no localization seam — the title and message are literals it stores. Adding one is a core change, not a satellite one. Flagged rather than quietly matched. - Recipients are hub-wide, not per author. With more than one hub on a mesh, a recipient named
by hub A is pinged about hub B's posts too. Every mesh measured so far has exactly one publishing
hub (memex-cloud, 2026-09-07), so per-hub routing would be untested machinery answering a question
nobody has asked.
PostNotificationWatcher.PolicyOfis the function to split when a second hub with a different audience exists.
A stored platform token is CIPHERTEXT on the node (2026-09-12)
A LinkedIn access token grants posting as the member. Until this change the connect flow wrote
both tokens in plaintext into {profile}/_ApiCredentials/linkedin, so anyone who could read that
node — through a grant on the profile, a support export, a database backup — read a usable token
(#174). Profile nodes live under shared spaces, not a person's private partition, so "who can read
the node" is a wider set than "who owns the account". Every other stored credential in the platform
already went through IProviderKeyProtector: a model provider's API key, the GitHub PAT, the
Executive Assistant's Entra credential, the plugin catalog's signing key.
PlatformCredentialProtection is the one seam. Every write of a credential node goes through
ProtectTokens, every read through Read. On the node the tokens are enc:v1: ciphertext; in
memory they are the real tokens, for the duration of one request.
Fail on the way IN, tolerate on the way OUT
That asymmetry is the platform's and is kept verbatim.
ProtectTHROWS when no master key is configured. A missing master key is a configuration fault, not a degraded mode — it used to pass the plaintext through, which is how a production deployment stored a live provider key in the clear on 2026-08-24. Both connect endpoints catch the refusal and redirect withreason=key-protection-unconfigured; nothing is written. A member who cannot connect is a worse day than one who can; a member whose token is sitting in a backup in the clear is a worse month.Unprotectpasses an UNTAGGED value through. Every credential written before this change keeps working, and re-encrypts the next time the member reconnects (Protectis idempotent, so nothing has to know which form a given node is in). There is no migration to run, and no flag day.
🚨 The postcondition is asserted, not assumed. ProtectTokens checks that what came back carries
the tag before it hands the record on. Protect throws today, so the check is belt-and-braces — but
the defect it guards against is exactly a protector that returns its input unchanged, and that defect
shipped a live credential to production with nothing failing at the call site. One comparison on a
security boundary is cheaper than discovering the regression from a backup.
"Cannot decrypt" is NOT "never connected"
A tagged token this deployment's master key cannot open — a rotated key, a key provisioned on one instance and not another — means the connection exists and is unusable. The member must RECONNECT. Reporting it as an absent credential tells them to connect an account they can see they already connected, which makes a key incident read as a broken connect flow.
So Read answers with a StoredCredentialState, never a nullable, and no call site can collapse the
two by accident:
| state | what it means | what the person is told |
|---|---|---|
Absent |
no credential node — or the reader may not read it | connect LinkedIn |
Undecryptable |
stored, will not open here | reconnect LinkedIn (credential-unreadable-reconnect; the Page sync redirects reason=reconnect-required) |
Usable |
decrypted, or legacy plaintext | — |
🚨 A read DENIED by access control reads as Absent, deliberately. "You may not read this
credential" must never become "reconnect LinkedIn": that would tell a member to re-authorize because
of someone else's permissions, and would confirm to them that a credential is there at all.
Three things the review caught that the encryption itself did not
Landed in the same change set as the review of #175.
- 🚨 An added OPTIONAL parameter is not a compatible change. #174 gave
LinkedInPublishService's existing constructor a fourth optional parameter. Optional arguments are filled in by the CALLER, so the three-parameter constructor stopped existing in metadata and a host compiled against the previous assembly would have failed at RUN time withMissingMethodException— invisible here, because every call site in this repo is recompiled together. There is now a three-argument forwarding overload, the four-argument one's protector is non-optional so the two can never be ambiguous, and a REFLECTION test pins both (source cannot see this; metadata can). - 🚨 A local refusal must not be answered as an upstream outage.
credential-unreadable-reconnectis decided before anything is sent to LinkedIn, and it fell toHttpStatusFor's default — 502 Bad Gateway, which an API caller retries waiting for LinkedIn to recover from something LinkedIn never did. It now answers 409, with the other reconnect-required codes, andHttpStatusForis public so a test pins every arm rather than a reader. - 🚨 A refusal nobody can read is the same as no refusal. The org sync endpoint has always
redirected to
/{page}?sync=error&reason=…and nothing on that page read it — the only query banner in the repo handles the member connect flow on the profile page. A refused sync therefore rendered as a Page still Connected with Sync still offered, and #174 routed a key-rotation failure through that same unread parameter.LinkedInOrganizationDocs.SyncBannernow renders it above the badge it contradicts, with a different sentence per slug (reconnect for a credential that will not open, connect for one that is not there) and the same query-string WHITELIST the profile banner settled on, because a crafted link is otherwise arbitrary Markdown on someone else's Page.
The member connect flow gained the same treatment: stage=store now has its own branch saying
connecting again will fail the same way and naming the administrator, instead of the generic
"try connecting again" — that failure is this deployment refusing to write a plaintext token, so a
retry cannot close the loop, and the message no longer attributes the refusal to LinkedIn.
What this does NOT do, said plainly
- A refreshed token is still not persisted.
LinkedInPublisher.EnsureFreshAsyncreturns a refreshed credential that lives for the request and is never written back — that was true before this change and is unchanged by it. The consequence is a refresh on every expired publish rather than a stored secret, so it is not a leak; it is a separate piece of work. - Nothing re-encrypts the rows already on disk on a schedule. They are read as plaintext until the member reconnects. A deployment that wants them gone sooner asks its members to reconnect; there is no background writer touching credentials nobody asked it to touch.