A post-growth, peer-reviewed federated social network, built on ActivityPods.
Statut : plan initial — 2026-06-06. Document vivant, à amender au fil des décisions.
Toutes les décisions doivent respecter les 9 contraintes Kind (voir Figma). En cas de tension entre contrainte produit et confort technique, la contrainte gagne.
Trois règles d'architecture qui en découlent :
- Server-enforced first — toute contrainte doit être imposée côté serveur. Le frontend peut aider l'UX mais ne doit jamais être l'unique gardien (17/jour, peer review, fenêtre horaire).
- Sémantique partout — sources, auteurs, thèmes, approbations sont des liens RDF, jamais des chaînes libres. Kind est un graphe, pas un blob.
- Pas de feed — l'API serveur ne doit pas exposer de timeline infinie. Le frontend ne sait afficher qu'une lettre à la fois.
Namespace : https://kind.app/ns#
Concepts à définir dans une ontologie custom (les autres sont réutilisés depuis AS2, Schema.org, DC, FOAF, ACL — déjà bundlés dans ActivityPods).
| Terme | Type | Description |
|---|---|---|
kind:Letter |
Class | Sous-classe de as:Article |
kind:Source |
Class | Référence citée (URL + métadonnées) |
kind:Circle |
Class | Cercle d'intérêt (mappé sur un acl:Group) |
kind:Approval |
Class | Acte d'approbation par un pair |
kind:status |
Property | Lettre → kind:Draft | kind:InReview | kind:Published (le rejet renvoie en Draft) |
kind:approvedBy |
Property | Lettre → WebID (un par approbation) |
kind:rejectedContentHash |
Property | Lettre → sha256 du contenu au moment du rejet. Bloque la resoumission tant que le contenu n'a pas changé |
kind:rejectionReason |
Property | Lettre → texte libre du rejet (visible par l'auteur) |
kind:reviewThreshold |
Retiré 2026-06-06 — seuil fixé à 2 pour toute lettre, hardcodé dans KindPeerReviewService |
|
kind:respondsTo |
Property | Lettre → autre Lettre (chaîne de réponses) |
kind:sources |
Property | Lettre → liste de kind:Source |
kind:circle |
Property | Lettre → kind:Circle (audience) |
kind:circleOwner |
Property | Cercle → WebID du propriétaire (un seul). Transmissible via kind-circles.transferOwnership |
kind:theme |
Property | Lettre → URI de thème (concept SKOS) |
- Activity Streams 2.0 :
as:Article,as:Note,as:attributedTo,as:to,as:cc,as:published - Schema.org :
schema:citation,schema:author,schema:datePublished,schema:about - Dublin Core :
dc:language,dc:created,dc:modified - FOAF :
foaf:Person,foaf:img,foaf:name - ACL :
acl:agentGroup(pour les cercles) - SKOS :
skos:Concept(pour les thèmes — taxonomy contrôlée si on veut)
{
"@context": [
"https://www.w3.org/ns/activitystreams",
{
"dc": "http://purl.org/dc/terms/",
"schema": "http://schema.org/",
"kind": "https://kind.app/ns#"
}
],
"id": "https://alice.kind.app/letters/we-need-night-trains",
"type": ["Article", "kind:Letter"],
"attributedTo": "https://alice.kind.app/",
"name": "We need night trains!",
"content": "Odio nibh eget nibh felis in...",
"dc:language": "en",
"dc:created": "2025-02-23T10:00:00Z",
"published": "2025-02-25T14:00:00Z",
"schema:about": "https://kind.app/themes/mobility",
"kind:status": "kind:Published",
"kind:respondsTo": "https://philippe.kind.app/letters/vision-train-network",
"kind:approvedBy": [
"https://lucie.kind.app/",
"https://anton.kind.app/"
],
"kind:circle": "https://alice.kind.app/circles/europe-mobility",
"schema:citation": [
{
"type": "Link",
"href": "https://metro.co.uk/2025/03/19/a-new-tube-europe-link...",
"schema:name": "A new tube linking 39 European countries via train",
"schema:publisher": "Metro"
}
],
"to": ["https://alice.kind.app/circles/europe-mobility"]
}Un shape tree par concept majeur, dans backend/shapetrees/kind/ :
Letter.ttl— contraintes SHACL surkind:LetterSource.ttl— contraintes surkind:SourceCircle.ttl— contraintes surkind:Circle
L'app demande accès à ces shape trees au moment du consent (mécanisme Apps Access Grants d'ActivityPods 2.0).
L'app ActivityPods de Kind est un node Moleculer qui s'enregistre auprès d'un Pod Provider. Architecture standard SemApps. On ajoute 3 services custom + 1 middleware + 1 ontologie :
Responsabilité : gérer le cycle de vie Draft → InReview → Published d'une lettre, gater la fédération.
Actions exposées :
submitDraft({ letterUri })— passekind:statusdeDraftàInReview. N'appelle paspod-outbox.post. Refuse sikind:rejectedContentHashexiste et matche le hash actuel du contenu (resoumission interdite sans modification, décision 2026-06-06).approve({ letterUri, approverWebId })— vérifie que l'approver est dans le cercle, ajoute àkind:approvedBy. Sicount >= 2→ appelle_publish().reject({ letterUri, approverWebId, reason })— passekind:statusàDraft, stockekind:rejectedContentHash(sha256 du contenu actuel) etkind:rejectionReason. Émet une notification (LDN) vers l'auteur._publish({ letterUri })(interne) — appellepod-outbox.postavec unCreateactivity contenant la lettre, audienceto= membres du cercle.
Listening :
- Hook sur
ldp.resource.deletedpour cleanup des approvals en cours
Stockage : tout en LDP dans le Pod de l'auteur, avec ACL serrée jusqu'à publication (visible uniquement par l'auteur + les reviewers désignés).
Middleware Moleculer (backend/middlewares/rate-limit.js).
S'insère uniquement sur l'action kind-peer-review.submitDraft — décision produit du 2026-06-06 : seuls les actes d'envoi au public comptent (écrire ou répondre, indifféremment). Approuver, sauvegarder un draft, lire = 0 action.
Logique :
- Lit
ctx.meta.webId - Incrémente un compteur (Redis ou Moleculer cacher) avec clé
rate:${webId}:${YYYY-MM-DD}et TTL 24h - Si > 17 → throw
MoleculerError("Daily limit reached", 429, "RATE_LIMIT")
Configuration dans moleculer.config.js → middlewares: [require('./middlewares/rate-limit')].
Responsabilité : enrichir une kind:Source avec ses métadonnées (Open Graph / oEmbed).
Actions :
enrich({ url })— fetch l'URL, extrait OG tags, retourne{ title, author, publisher, image, description }- Stocke en cache pour éviter de re-fetcher la même URL
Sécurité : whitelist de domaines ou rate-limit serveur sortant (un user malveillant ne doit pas pouvoir faire fetcher 1000 URLs).
Décision 2026-06-06 : on coupe l'écriture de 22h à 7h dans le fuseau horaire de l'auteur. (Pas de mention du weekend dans la décision, gardons-le ouvert le weekend pour l'instant.)
- Lit
ctx.meta.webIdpuis fetcheschema:timezonedu profil (fallbackEurope/Paris) - Calcule l'heure locale courante
- Si 22h ≤ heure < 7h → throw
MoleculerError("Service closed (22h–7h)", 503, "SERVICE_CLOSED") - S'applique sur
kind-peer-review.submitDraft,kind-peer-review.approve,kind-peer-review.reject, mais aussi sur les sauvegardes de draft (dataProvider.updatesur Letter) — sinon on contourne en sauvant un draft pendant la nuit - Lecture (
dataProvider.getOne,getList) toujours autorisée
À reconsidérer : ajouter une coupure weekend ? Décision pendante.
Décision 2026-06-06 : autogestion par le créateur. Pas de modération centrale.
Actions :
create({ name, description })— crée unkind:Circle, posekind:circleOwner = ctx.meta.webId, crée un groupe WAC (PodWacGroupsService.create).invite({ circleUri, memberWebId })— vérifiectx.meta.webId == kind:circleOwner, ajoute le membre viaPodWacGroupsService.addMember. Envoie uneInviteactivity AP àmemberWebId.removeMember({ circleUri, memberWebId })— idem, retire.transferOwnership({ circleUri, newOwnerWebId })— vérifie owner actuel, vérifie quenewOwnerWebIdest déjà membre du cercle, changekind:circleOwner.delete({ circleUri })— owner uniquement. Détruit le groupe WAC. Les lettres déjà publiées restent accessibles à leurskind:approvedBymais perdent leur lien vers le cercle disparu.
backend/services/core/ontologies.js ajoute :
{ prefix: 'kind', url: 'https://kind.app/ns#', owl: '/path/to/kind.ttl' }Fichier backend/ontologies/kind.ttl avec la définition OWL/RDFS de kind:Letter, kind:Source, etc.
- Vite (pas Next.js — Kind n'a pas besoin de SSR, et CSR + SPA est suffisant)
- React 18+
- React Router pour le routing
- SemApps packages bas niveau :
@semapps/auth-provider— login Solid OIDC@semapps/semantic-data-provider— fetch/save LDP resources@semapps/activitypub-components— hooksuseOutbox,useInbox
react-i18next— bilingue FR/EN par défaut (décision 2026-06-06)- Pas de react-admin, pas de Material-UI
- Typographie : serif (Newsreader ou EB Garamond) pour le corps des lettres, sans-serif (Inter) pour le chrome
- Pas de framework CSS lourd — CSS modules ou vanilla-extract
- Palette : fond ivoire
#fefefe, encarts saumon clair#f5e8e6, texte#1a1a1a, accents discrets
/ → Accueil
/read → liste *minimale* des lettres reçues (1 par "page", navigation suivant/précédent)
/read/:letterId → lettre + sidebar sources + thread de réponses
/write → composer (vide)
/write/:draftId → composer (édition d'un draft)
/about → page statique
/me → profil + drafts + lettres en attente de review
/me/review/:letterId → interface d'approbation pour un pair
/login → Pod login
src/
components/
Layout.tsx # nav haute (Read/Write) + basse (About/Me)
Letter.tsx # rendu d'une lettre (lecture)
LetterSidebar.tsx # When? About? Approved by? Sources?
LetterEditor.tsx # composer 500 mots max, autosave draft
SourceCard.tsx # carte preview d'une source citée
SourceModal.tsx # modal grand format (cf. "It's Nice That" dans Figma)
Thread.tsx # liste des réponses sous la lettre
DailyLimitBadge.tsx # indicateur "5/17 actions aujourd'hui"
ReviewQueue.tsx # interface d'approbation pair
CircleSelector.tsx # choix de l'audience (cercle d'intérêt)
LanguageSwitcher.tsx # FR / EN, persiste dans le profil (`schema:knowsLanguage`)
RejectionNotice.tsx # bandeau sur un draft rejeté avec la raison + bouton "modifier"
ClosedNotice.tsx # écran 22h-7h "Kind est fermé jusqu'à 7h dans votre fuseau"
pages/
ReadPage.tsx
WritePage.tsx
MePage.tsx
AboutPage.tsx
hooks/
useDailyLimit.ts # wraps fetch /me/rate-status, cache 1min
usePeerReview.ts # actions submit/approve/reject
useLetter.ts # useGetOne wrapped
lib/
dataProvider.ts # config SemApps semantic-data-provider
authProvider.ts # config SemApps auth-provider
Écrire une lettre :
- User clique "Write" →
LetterEditoravec draft vide - Autosave (debounced 2s) →
dataProvider.update()sur la ressource LDP draft (statutkind:Draft) - User clique "Envoyer en relecture" → appel
kind-peer-review.submitDraftvia outbox custom action - Status passe à
kind:InReview, draft visible par les reviewers dans leur/me/review
Lire une lettre :
- Route
/read/:letterId→useGetOne('Letter', letterId) - Le data provider expand le JSON-LD : auteur (FOAF), sources (kind:Source), approvers (kind:approvedBy)
- Rendu :
<Letter>+<LetterSidebar>+<Thread>
Approuver :
- Reviewer va sur
/me/review/:letterId - Lit, clique "Approuver" → appel
kind-peer-review.approve - Si seuil atteint → publication automatique → fédération AP
Point clé (corrigé 2026-06-06) : dans ActivityPods, une app est toujours provider-agnostique. On ne déploie pas Kind "sur" un Pod Provider — on déploie Kind comme app autonome, et n'importe quel Pod Provider compatible peut s'y connecter.
Pod Provider (armoise.co, mutual-aid.app, votre serveur…)
↑ Solid LDP + ActivityPub
│
App server Kind (kind.app — c'est ce qu'on déploie)
↑ HTTP
│
Frontend Kind React (kind.app — c'est ce qu'on déploie)
Fichier backend/services/app.service.js :
const { AppService } = require('@activitypods/app');
module.exports = {
mixins: [AppService],
settings: {
app: {
name: 'Kind',
description: 'A post-growth, peer-reviewed federated social network',
thumbnail: 'https://kind.app/logo192.png',
frontUrl: 'https://kind.app'
},
oidc: {
clientUri: 'https://kind.app',
redirectUris: 'https://kind.app/auth-callback',
postLogoutRedirectUris: 'https://kind.app/login?logout=true',
tosUri: 'https://kind.app/terms'
},
accessNeeds: {
required: [
{ shapeTreeUri: 'https://kind.app/shapetrees/Letter', accessMode: ['acl:Read', 'acl:Write'] },
{ shapeTreeUri: 'https://kind.app/shapetrees/Source', accessMode: ['acl:Read', 'acl:Write'] },
{ shapeTreeUri: 'https://kind.app/shapetrees/Circle', accessMode: ['acl:Read', 'acl:Write'] },
'apods:ReadInbox',
'apods:ReadOutbox',
'apods:PostOutbox',
'apods:CreateAclGroup'
]
}
}
};Le mixin gère pour nous : exposition du manifest, OIDC Dynamic Registration, génération de l'écran de consentement, stockage des Apps Access Grants.
- Alice (compte existant sur armoise.co) visite kind.app, clique "Login"
- Kind demande son WebID ou son Pod Provider → redirect OIDC vers armoise.co
- armoise.co fetche le manifest de Kind, affiche un écran de consentement listant les access needs
- Alice clique "Autoriser" → un Apps Access Grant est créé dans son Pod
- Kind reçoit un token, peut lire/écrire
kind:Letteretc. dans le Pod d'Alice sur armoise.co
C'est une décision séparée de "déployer Kind". Trois options :
- A. Pas de Pod Provider Kind — on demande aux users d'avoir déjà un Pod ailleurs (armoise.co, mutual-aid, autohébergé). Plus pur philosophiquement, onboarding plus rude.
- B. Pod Provider Kind dédié (
pod.kind.app) — on offre l'inscription clé-en-main, on garde la philosophie "solar server" pour le hosting. Devops à porter. - C. Hybride : dev sur armoise.co, prod sur
pod.kind.appquand on lance vraiment.
Reco actuelle : C. En Phase 0-2, créer 2 comptes test sur armoise.co. En Phase 3+, monter pod.kind.app (Docker, fork du pod-provider officiel ActivityPods).
- Pod Provider local via Docker (
activitypods/pod-provider) - 2 comptes test (
alice,bob) - Squelette Vite + React, login Solid qui marche
- Page
/readqui affiche "hello WebID"
Critère de sortie : alice se logge, voit son WebID.
- Ontologie
kind:déclarée + shape trees - Service
KindPeerReviewService(sans fédération encore) - Frontend :
LetterEditor,Letter,MePageavec drafts - Flow complet : alice écrit → soumet → bob approuve → publié dans le Pod d'alice
Critère de sortie : un cycle complet review/publish marche sur un seul Pod.
KindSourceService(OG / oEmbed)SourceModalfrontend- Publication via
pod-outbox.post→ fédération ActivityPub réelle entre 2 Pods - Inbox côté lecteur (réceptionne les lettres fédérées)
Critère de sortie : alice@pod1 publie, bob@pod2 reçoit dans son inbox.
KindRateLimitMiddleware(17/jour)DailyLimitBadgefrontendPodWacGroupsServicebranché : création/membership de cerclesCircleSelectordans l'éditeur
Critère de sortie : limite serveur réelle, lettre visible uniquement dans son cercle.
À évaluer selon priorités :
- Typographic voices (choix de fonts par user)
- Controversy mapping (visualisation graphe)
- Closed at night & weekends (middleware optionnel)
- Solar server (déploiement low-tech, à séparer du dev)
Pod Provider : on auto-héberge sur quelle infra ?Tranché 2026-06-06 : hybride — dev sur armoise.co (comptes test), prod future surpod.kind.app(Phase 3+). Voir section 5.3.Seuil de review : 2 approbations suffisent-elles ?Tranché 2026-06-06 : fixe à 2 pour toutes les lettres. À ré-évaluer si on observe des chambres d'écho (2 amis qui se valident en boucle) — solution probable à ce moment-là : exiger 2 approbations de personnes hors des 5 derniers réviseurs.Que se passe-t-il si une lettre est rejetée ?Tranché 2026-06-06 : message d'info à l'auteur, lettre repasse enkind:Draft. Resoumission interdite tant que le contenu n'a pas été modifié (vérification par hash du contenu au moment du rejet, stocké danskind:rejectedContentHash).Compteur des 17 actions : qu'est-ce qui compte exactement ?Tranché 2026-06-06 : écrire et répondre = 1 chacun, approuver/draft/lire = 0. Une action = un passage en review (submitDraft).Fenêtre horaire (idée board)Tranché 2026-06-06 : on garde. Fermeture des actions d'écriture de 22h à 7h dans le fuseau de l'auteur (schema:timezonedu profil, fallback Europe/Paris). Lecture toujours autorisée. Réponse serveur503 Service Closed.KindTimeWindowMiddlewaredevient obligatoire (pas optionnel).Modération des cerclesTranché 2026-06-06 : autogestion. Le créateur (kind:circleOwner) gère seul (invite, exclut, supprime). Il peut transmettre la propriété à un membre via une actionkind-circles.transferOwnership. Pas de modération centrale.Mode hors-ligne / draft localTranché 2026-06-06 : autosave côté serveur (dans le Pod), pour pouvoir reprendre depuis un autre appareil. Pas d'IndexedDB en v1. Conséquence : pas d'édition offline en v1 — à reconsidérer si demande utilisateur forte.i18nTranché 2026-06-06 : bilingue FR + EN par défaut. Sélecteur de langue UI. Les lettres elles-mêmes gardent leurdc:languagepropre (un user FR peut publier en anglais).
| Risque | Impact | Mitigation |
|---|---|---|
| ActivityPods change l'API Apps Access Grants en v3 | Refonte du backend | Suivre les releases, contributer en amont |
| Le pattern peer-review n'a pas d'exemple dans la communauté | Plus de R&D que prévu | Spike en début de Phase 1, partager le pattern avec l'équipe SemApps |
| WAC groups ↔ AP audience : pas de pont auto | Logique custom à écrire pour résoudre les membres d'un cercle au moment du post | Helper côté KindPeerReviewService._publish |
| Fédération entre apps Kind sur Pods différents | Sources distantes pas dans le bon Pod | LDN (Linked Data Notifications) ou simple as:Link externe |
| Adoption / discoverability | Sans suggestion algorithmique, comment les users trouvent leurs cercles ? | Onboarding éditorial humain en phase 1 |