Skip to content

docs: add organization members and contacts storage adr - #4917

Open
rezk2ll wants to merge 1 commit into
masterfrom
chore/org-contacts-storage-adr
Open

rezk2ll wants to merge 1 commit into
masterfrom
chore/org-contacts-storage-adr

Conversation

@rezk2ll

@rezk2ll rezk2ll commented Sep 14, 2026

Copy link
Copy Markdown
Member

Status

Proposed

Date

2026-09-14

Context

  • Members are the users of the organization, from LDAP.
  • Contacts are created by a user and are personal.
  • The stack stores both as io.cozy.contacts, and today copies every member and group into every instance of the organization.

The common contacts ADR makes Sabre the source of truth: apps read contacts from twake:contacts:common exchange and publish the ones they collect on twake:contacts:collected exchange.

Sharing reads contacts on the caller's instance to add recipients, to follow group changes and to auto accept. That breaks once members are no longer copied there.

Decision

  • Members live on the organization instance. Contacts live on the user's instance.
  • With common contacts, contacts come from twake:contacts:common exchange. The stack still writes one itself when it needs it immediately (sharing with an unknown email, answering a sharing), and the feed updates it when it comes back.
  • Without common contacts (standalone), the stack keeps today's writers.
  • Sharing accepts people by email and groups by id, looked up on the org instance first, then on the user's instance.

Instances without an OrgID are unchanged.

Where members and contacts come from

flowchart LR
  subgraph rabbitmq[RabbitMQ]
    sabreEx{{exchanges sabre:contact:*}}
    common{{exchange twake:contacts:common}}
  end
  sabre[(Sabre)] --> sabreEx
  sabreEx --> css[contacts side service]
  css -->|JSContact + audience| common
  common --> stack[cozy-stack]
  common --> others[chat, mail, ...]
  stack -->|audience.domain| org[(org instance)]
  stack -->|audience.user| usr[(user instance)]
Loading

twake:contacts:common is a fanout exchange with no routing key, so the stack receives every change for every user and domain. The message says what to do:

  • action: ADD, UPDATE or DELETE. ADD and UPDATE carry the full contact, so replaying one changes nothing.
  • audience.domain: a member, written on the org instance.
  • audience.user: a personal contact, written on that user's instance.
  • Empty audience: dropped.

Members reach the domain address book through the side service, which consumes user.created (ADR 058).

The contact comes as JSContact. The vCard properties JSContact does not cover stay in vCardProps, and the stack reads x-twake-workplace-fqdn there to fill the contact's cozy URL, the address sharing uses to reach that member's instance.

A personal contact only names its owner by email, and the stack cannot find an instance from an email today. So the user.created handler stores internalEmail on the instance. It writes no contact anymore, and still sets the passphrase, the vault keys and the Matrix ID.

Each message carries the CardDAV path of the contact: its address book and its file name. The stack stores that path and uses it to find which document the message is about. The uid is not enough, the same one can sit in several address books.

When a contact changes

  • Updated: the stack overwrites it and keeps its own fields (cozy URL, trustedForSharing). Existing sharings keep the name and email they were created with.
  • Removed: the stack deletes the document. A recipient added by email keeps their access, the sharing already holds their name, email and instance. A recipient who is only there because of a group is removed from the sharings using that group, as today.

Sharing with an email

This section is about who writes the contact document. Finding people to share with is the suggestions ADR.

When the email matches a contact, on the org instance first, the member gets its name and instance URL.

When it matches nothing, the stack creates the contact on the user's instance, shares with it, which sends an invitation, and collects the address:

sequenceDiagram
  participant S as cozy-stack
  participant R as RabbitMQ
  participant C as contacts side service
  participant D as Sabre

  S->>S: create the contact on the user's instance
  S->>R: publish on exchange twake:contacts:collected
  R->>C: collected contact
  C->>D: add to the user's collected address book
  D->>R: publish on exchange sabre:contact:created
  R->>C: created contact
  C->>R: publish on exchange twake:contacts:common, audience.user
  R->>S: contact with its CardDAV path
  S->>S: update the contact it created
Loading

The contact created at share time has no CardDAV path yet, it only holds the email. So for that first message the stack matches on the email and fills in the path, the name and the rest of the card. From then on the document is keyed by its path like any other.

Sharing never waits for any of this. The document exists before the invitation goes out, the publish is fire and forget, and a contacts service that is down changes nothing for the sharer: no rollback, no retry loop. The contact stays as the user typed it until Sabre answers.

The detour matters because Sabre is the source of truth: the address ends up in the user's collected address book, so the same contact reaches the other apps and not only the stack.

In standalone mode there is no feed, so the contact stays as the stack created it ( same as today ).

The document is not only a copy of Sabre. trustedForSharing and the cozy URL belong to the stack, and a message from the feed never overwrites them.

twake:contacts:collected is also a fanout exchange. The stack publisher refuses an empty routing key, so it needs a change. ( some refactor needed )

Auto accept

A sharer is trusted when their instance has the same OrgID as the recipient. trustedForSharing stays for people outside the organization.

Answering a sharing is the other place where the stack writes a contact itself: it creates the sender's contact on the recipient instance and marks it trusted. That stays for people outside the organization. Between members, the OrgID decides and nothing is written.

Staying in sync

The side service can republish every address book: every contact in Sabre is sent again as an ADD. The stack writes them like any other message, which fills a new org instance or fixes contacts it missed. Then it deletes the old copies on member instances with BulkDeleteDocs, so the share-group trigger does not revoke group members.

A republication repairs a lost ADD or UPDATE, not a lost DELETE. To catch those, the stack wants a periodic reconciliation on the org instance: compare what Sabre sends with what the instance holds, and remove the rest. That needs the republication to be a full set the stack can trust, which is the open question below.

Consequences

  • A member change is one write instead of one per instance.
  • Sharing between members depends on the org instance.

Open questions

  • Blocked contacts: what does blocking mean for the stack and drive? ( ignored for now )
  • What identifies a contact and a group outside the stack? The stack uses the CardDAV path for contacts, and groups have no equivalent yet. Do we keep the CouchDB ids we have today, or do we key both on an id coming from the source? @shepilov
  • A sharing created on a personal instance points at a group that now lives on the org instance. How do we move those sharings, and how do they keep following the group? @shepilov
  • Groups: how do they reach twake:contacts:common (maybe as a label on the contact)? Until then, the stack keeps building them from b2b.group.*.
  • When a member leaves a group, what happens to the sharings using it? Today the share-group trigger removes them.
  • Existing group sharings use b2b-group-<hash> ids. How do they keep matching their groups?
  • Domain groups can have thousands of members, and the stack reads at most 1000 per group, do we need to change that?
  • A message delivered out of order can overwrite a newer contact until the next republication. we need to have some sort of revision id or time stamping from sabre?
  • A missed DELETE is not repaired: the republication only sends ADD, so a contact deleted in Sabre stays in the stack. Does the side service need a full sync mode, or does the stack remove contacts that a republication did not send?

Comment thread docs/adr/006-adr-organization-contacts-storage.md Outdated
Comment thread docs/adr/006-adr-organization-contacts-storage.md Outdated

- Organization contacts and groups are stored on the organization instance only, when the organization has one.
- When the context has a contacts side service, organization contacts come from `twake:contacts:common`, and the stack's `user.created` and `domain.user.deleted` handlers stop writing contacts.
- Personal contacts stay on the user's instance.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't know if the sharing process & sharing contacts has been written in the common contacts ADR. Can you check that?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

no it hasn't been mentioned in the common contact ADR ( not in details ), it was just mentioned that the shared drive will be a use case to consume twake:contacts:common exchange. i added the exact details in this ADR


- The stack adds a consumer on `twake:contacts:common`. A message with `audience.domain` writes the contact on the org instance of that domain, one with `audience.user` on that user's instance.
- `user.created` (queue `stack.user.created`) and `domain.user.deleted` (queue `stack.b2b.user.deleted`) no longer write contacts. `user.created` is still consumed for the passphrase and the Matrix ID.
- `b2b.group.*` (queue `stack.b2b.group.lifecycle`) is applied to the org instance only, until groups arrive on `twake:contacts:common`. A member added to a group before their contact arrives is created from the group event, then updated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how can we assure that we've the same contacts at the end?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  • The stack consumer will be ( and should be ) idempotent: ADD/UPDATE will carry the full contact, and documents are keyed by CardDAV path, so replaying a message changes nothing.
  • The contact side service can republish the entire address book ( ADR 43 ), which the stack uses to either fill a new org instance or repair contacts the stack missed (RabbitMQ failures maybe )

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we should have a reconciliation job just in case something was lost in mq or anywhere

@rezk2ll
rezk2ll force-pushed the chore/org-contacts-storage-adr branch 2 times, most recently from 6a17934 to b9829dc Compare September 15, 2026 12:11
@rezk2ll rezk2ll changed the title docs: add organization contacts storage adr docs: add organization members and contacts storage adr Sep 15, 2026
@rezk2ll
rezk2ll requested a review from Crash-- September 15, 2026 13:05
@rezk2ll
rezk2ll force-pushed the chore/org-contacts-storage-adr branch from b9829dc to c5e5a24 Compare September 15, 2026 13:15
@rezk2ll
rezk2ll requested a review from chibenwa September 15, 2026 13:25
- `audience.user`: a personal contact, written on that user's instance.
- Empty audience: dropped.

Members reach the domain address book through the side service, which consumes `user.created` ([ADR 058](https://github.com/linagora/twake-workplace-private/pull/1640)). The member's cozy URL comes from `x-twake-workplace-fqdn` in the JSContact `vCardProps`.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how it's connected to the stack and this ADR? do we need to take care of vCard?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

since you receive a Vcard as a contact, the stack needs to parse the x-twake-workplace-fqdn to resolve the contact workspace url ( instance URL )

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

but where I recieve Vcard as a contacts and why? in rabbitmq ?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes via RabbitMQ, it's mentioned in the "Where members and contacts come from"


A personal contact only names its owner by email, and the stack cannot find an instance from an email today. So the `user.created` handler stores `internalEmail` on the instance. It writes no contact anymore, and still sets the passphrase, the vault keys and the Matrix ID.

A document is keyed by its CardDAV `path`, because the same `uid` can sit in several address books.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the same for this, what document, what CardDAV?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here, it means the uniqueness is guaranteed by the path of the contact in cardav

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ok, so CardDAV is the main protocal of communication between services and "common contacts", and we rely on CardDAV path as and identifier?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

that's the unique identifier we have in sabre yes

Comment thread docs/adr/006-adr-organization-contacts-storage.md Outdated
When it matches nothing, the stack shares with the email, which sends an invitation, and collects the address:

```mermaid
sequenceDiagram

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we are still mixing things in this ADR, to have contacts only on org instance and contacting "contact side service"

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The collected flow is here because it decides who writes the contact document ( and after this ADR it is no longer the stack )

@shepilov shepilov Sep 16, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

mmm, why? it shouldn't be stack, it a stack's database, nobody except stack should write there if we are talking about the same thing

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the stack will write there but only if it confirms that contact is replicated and available globally. so it's writing with extra verification steps

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think that we need this remote transaction here, and for example, rollback sharing if the contact service is not available, or wait for the rabbitmq update, we can do it only if we consider io.cozy.contacts only as cache. I'll check how do we use it during sharing, but at least we are storing "trusted" flag there, and it will be strange to wait for it with remote rabbitmq call

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hmm, you are right. i updated the ADR

- A member change is one write instead of one per instance.
- Sharing between members depends on the org instance.

## Open questions

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Whan more open question that should be addressed in this ADR, what is external identifier for contact and group? the same uuid that we have right now?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good question; I do not want to / can't decide it alone. For contacts, we have the CardDAV path; for groups, nothing has been decided yet, and @chibenwa prefers that it should be tackled another time ( it can be labels on a contact for example )

Comment thread docs/adr/006-adr-organization-contacts-storage.md
- When a member leaves a group, what happens to the sharings using it? Today the `share-group` trigger removes them.
- Existing group sharings use `b2b-group-<hash>` ids. How do they keep matching their groups?
- Domain groups can have thousands of members, and the stack reads at most 1000 per group, do we need to change that?
- A message delivered out of order can overwrite a newer contact until the next republication. we need to have some sort of revision id or time stamping from sabre?

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, if we want consistency
eventually)

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i'll keep it an open question, cc @chibenwa

Comment thread docs/adr/006-adr-organization-contacts-storage.md
@rezk2ll
rezk2ll force-pushed the chore/org-contacts-storage-adr branch from c5e5a24 to ce57e1e Compare September 16, 2026 09:55
@rezk2ll
rezk2ll requested a review from shepilov September 16, 2026 11:12
@rezk2ll
rezk2ll force-pushed the chore/org-contacts-storage-adr branch 2 times, most recently from efeb673 to f50890d Compare September 17, 2026 07:52
@rezk2ll
rezk2ll force-pushed the chore/org-contacts-storage-adr branch from f50890d to 6153f4c Compare September 17, 2026 09:19

This branch has not been deployed

No deployments
Sign up for free to 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.

3 participants