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:

🚨 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.ReferencedProfilePathsProfilePartitions); 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 1Doc/DataMesh/SocialMedia/Post/Post-001, a documentation sample
SocialMedia/PostsHub nodes on the mesh 1Posts

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:

  1. 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.
  2. 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.
  3. 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.
  4. 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

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.

🚨 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.

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

Reconnecting…
The server was updated. Reloading the page to pick up the latest version.