Skip to content
quarry Artifact [44a5c1db91]
Login

Artifact [44a5c1db91]

Artifact 44a5c1db9116d0b390fd956266ed4181fa772370a979458d383cf0bceda6bac0:


Summary

A team is a place to work that any user can make. Repositories sit under it, people are members of it with roles, and it gathers what those repositories are doing — their chat and their tickets — into one room.

RFC 0008 described the shape before this RFC existed: "a team is a name that expands to a role on a set of repositories." That is still the right sentence. What 0008 could not know is that the interesting half is not the expansion — it is the gathering. Access is one of five things a team does and the least remarkable one.

quarry has one level where every surveyed forge has two: an organisation that owns repositories, and teams inside it that assign permissions. One level is the right size for the projects quarry hosts, and it is the reason a quarry team is publicly listable where every other forge's team is not. The second level is possible and is not proposed; see §"What is not proposed".

The five things a team does

holds Repositories sit under the team. You add one you administer.
gathers people Members, each with a role that reaches every repository under it.
has a room Its own chat, which also carries the lines from every repository under it.
shows the work One ticket list across those repositories, grouped by assignee.
manages access One screen for everyone who reaches any of them.

The first two are structure. The last three are why anyone opens a team.

What the survey settles

Four findings where all four large forges agree. Deviating from any of them needs an argument this RFC does not have, so they are taken as given rather than re-decided.

Any user creates a team. Not one forge gates creation behind a site administrator. A team is not an admin feature.

Ownership is a role several people hold, never a field naming one. GitHub advises at least two Owners; Forgejo auto-creates an Owners team and puts the creator in it; GitLab and Bitbucket use roles held by any number. An owner column would be bus-factor one by construction, and a co-owner patch bolted on afterwards would make "who manages this" a different shape from "who can do what". It is a membership row.

A repository belongs to exactly one namespace, and joining is a transfer. GitHub, Forgejo and GitLab all move a repository in and none let it live in two; Bitbucket requires every repository to sit in exactly one project. Fossil corroborates from below: a repository may be in only one login group at a time. Exclusivity is the shape the substrate already chose.

Access is a union, highest wins. GitHub resolves base, team and direct-collaborator grants to the highest; Forgejo takes the max; GitLab makes an inherited role a floor that a subgroup cannot lower; Bitbucket is additive across three layers. No forge lets a team take access away, and four independent implementations declining to build restriction is the clearest signal in the survey.

Fossil is the instructive exception: its one cross-repository mechanism propagates capabilities by overwrite, replacing the target rows rather than combining them, and only for users who already exist under the same name. That is why teams live in quarry's own tables and resolve at read time — building an additive model on a destructive primitive would erase the per-repository differences fossil's own docs tell you to expect.

The model

CREATE TABLE team (
  name    TEXT PRIMARY KEY,          -- shares the ACCOUNT namespace
  title   TEXT NOT NULL DEFAULT '',  -- display name
  descr   TEXT NOT NULL DEFAULT '',  -- one sentence, public
  created TEXT NOT NULL,
  created_by TEXT NOT NULL           -- provenance; grants nothing
);

CREATE TABLE team_member (
  team    TEXT NOT NULL,
  account TEXT NOT NULL,
  caps    TEXT NOT NULL DEFAULT '',  -- ON TOP of the membership floor
  owner   INTEGER NOT NULL DEFAULT 0,-- manages the TEAM, not its repos
  listed  INTEGER NOT NULL DEFAULT 1,-- public on the team's page
  PRIMARY KEY (team, account)
);

CREATE TABLE team_repo (
  repo     TEXT PRIMARY KEY,         -- PK on repo: exclusivity, enforced
  team     TEXT NOT NULL,
  added_by TEXT NOT NULL,
  added    TEXT NOT NULL
);

-- team_chat was sketched here and NOT built; see the amendment in
-- §"Gathering chat". A team's own lines are a chat CHANNEL, not a
-- table.

team_repo keyed on repo alone is exclusivity enforced by the schema rather than by a check somebody can forget.

team_repo decides the path segment

A repository appears under a namespace — the segment in its URL — and repo.namespace stores it. This RFC did not say how the two relate, because when it was written nothing but an owner could put a segment there. A team can, so:

team_repo is the authority for which team holds a repository. repo.namespace says where the repository APPEARS, and MUST follow it: the holding team's name while a team holds it, the owner's name otherwise, and empty when nobody holds the store. A disagreement between them is by definition the path gone stale, never the reverse, so a disagreement is restored rather than reconciled.

The alternative was to stop storing the segment and resolve it from team_repo on every read. That cannot go stale, which is the better property, and it was still rejected: a rendered page carries hundreds of repository links, and paying a lookup on each one to avoid a write in two places is the wrong trade while the write can be made atomic instead. So it is made atomic — placing or removing a repository writes team_repo and repo.namespace in one transaction — and the stale shape is restored at startup rather than tolerated.

The deed never moves. A repository in a team still has an owner, and the team holds the path, not the store. This is what makes the rule safe in both directions and is worth stating rather than leaving implied: a team can be dissolved, or a repository taken out of one, without putting anybody's code at risk — the repository simply appears under its owner again. A model where joining a team transferred the repository would make dissolving a team a destructive act, and nobody would risk the feature.

Three consequences to state plainly:

Recorded because it has already cost something: a migration used an empty namespace_by to mean "a migration guessed this owner" and so would have un-owned a claimed repository that had been in a team and left it. The field answers who placed a segment and nothing else, and is not a discriminator for how a row came to exist.

created_by is provenance and nothing else. No authority resolves through it, and the creator holds none once they stop being an owner. A bare timestamp cannot answer "who made this and why does it exist".

Names come from the account namespace, and a name either kind holds is refused to the other. Both appear in the same "who can reach this" lists, and a reader should not have to know which kind a name is. It is also what keeps resolution honest: a grant row names a string, and if that string could be either, the forge would have to guess on every request.

Resolving access

One person's effective set on one repository becomes:

  1. the repository's categories (RFC 0004, unchanged),
  2. their own stored string (unchanged),
  3. the membership floor, if that repository is under a team they are in,
  4. their team row's caps, likewise,

then fossil's expansion as today — so a u from a team pulls in the reader category exactly as a personal u does, and a letter this build has no surface for stays stored and inert exactly as it does now.

One extra join, at the point the expansion already runs. Nothing about the expansion rules changes.

A team grant is a capability string

Not a role name. GitHub gives a team one of five repository roles; Forgejo gives none/read/write across eight units; quarry already stores authority per ability, which is finer than either. A coarse role would throw information away at the team boundary — a team could not express "issues: write, code: none", which quarry can already express for one person — and a unit model would invent a second vocabulary beside the letters.

So the string is stored and displayed as a role name when it matches a preset, which is exactly what the per-person editor already does with its four presets over a raw disclosure. The team page reuses that editor.

The membership floor

Being added to a team means you can see what the team is doing. The rule is read everything, participate freely, pollute nothing.

teamFloor = "ghjorz28C"

  g h j o r z   clone, links, wiki read, code read, ticket read, archives
  2             read the forum -- and landings, which are forum threads
  8             see CI runs, checks and logs   (quarry's own letter)
  C             chat -- quarry-local, so not a store write

Plus quarry's own ticket tier: ticket.intake gates on r rather than n (RFC 0017), so a member may file a local ticket and mint nothing.

Absent, because each writes the repository's own store: n file tickets, c comment on tickets, 3 post in the forum, m append to the wiki, k edit wiki, f new wiki pages, b/B attachments, i check in, w edit tickets, a settings, t q l 5 6 reports and moderation, x y private branches and unversioned files, e addresses. 7, mail alerts, is absent because it is a notification preference rather than access, and joining a team should not silently subscribe anyone to mail.

Why letters and not a preset. The obvious implementation is "membership means Reader", and it was not that when this was written: Reader was u3bBC78, and the u expanded through fossil's shipped reader category kptw — wiki editing and ticket editing (ticket 085786dd870e). That has since been fixed — Reader spells its letters out (RFC 0008 §"Reader stopped routing through the reader category"), so the two are now close by design rather than by accident. The floor still does not route through the preset, for the reason below, which never depended on the bug. Nor can the floor route through a category, because on a private repository both categories are empty by definition, and teams are mostly used on private repositories. The floor is the entire access story there, so it must be self-sufficient.

On a companion, the floor gains 3, B and 4 — posting, attaching and posting without moderation — because writing there does not touch the parent's store. That half arrives with RFC 0017; until then the floor is the string above on every repository, companion or not.

Where 0008 holds and where it does not

RFC 0008 said "nothing below needs changing to add one". Mostly true, and two exceptions must be stated rather than discovered:

A floor is not a name expanding to a role. 0008's model is that authority arrives because a name resolves to a grant. The membership floor is authority arriving from membership itself, with no grant behind it. That is a new source, and it is why the next section exists.

One gate moves. ticket.intake and ticket.new both read n today, so the local tier cannot be more relaxed than the store. RFC 0017 moves intake to r. That is a change below 0008's line.

Everything else holds: the letters, the presets, the categories, the expansion, the guard, and the editor are unchanged.

Three routes, each named

Union resolution moves the cost from granting to revoking. Bitbucket documents the consequence: removing a user from a project does not remove permissions granted at workspace or repository level, and each must be revoked where it was granted.

So every listing that answers "who can do this" must name the route:

route shown as
a stored string on the account Maintainer, directly
a team row's caps Maintainer via release-eng
the membership floor alone by membership of release-eng

The third is the one this RFC creates, and the one a page would otherwise have no way to explain.

Removing somebody from a team removes all of their access, not some of it. On a public repository they fall to what any visitor gets; on a private one there is nothing beneath the floor. Since private is the common case, that belongs on the button and not in a footnote. The same applies to moving a private repository between teams, which cuts off everyone in the old team the instant it moves.

Authority

action site admin s team owner repo admin a
create a team yes — —
delete a team only as an owner yes no
set title, members, roles only as an owner yes no
put a repository under the team only if they hold a on it only if they hold a on it yes
take it out only as owner or with a yes yes

Creation is any user's, so the first column is a site admin's ability to act, not a gate. Beyond creating, a site admin has no authority over a team they do not own (#318): a team's members reach every repository under it, so managing its members is a way into them.

You may only put a repository under a team if you administer it. Otherwise making a team is an escalation primitive: create one, take somebody else's repository, grant yourself admin. The check is the one that already exists, asked when the repository joins, and added_by records who passed it — because the capability that permitted it can later change hands.

Either side may take a repository out. Whoever can grant must be able to revoke, or the grant is not really theirs. A repository admin who is not a team owner must not lose control of their own access list.

The receiving half is an offer

Amendment, 2026-09-14. The rule above guards one direction and left the mirror of it open.

"You may only put a repository under a team if you administer it" stops a team from seizing a repository. It does not stop a repository from being pushed onto a team that never asked for it. Every member of that team silently gains the team's capabilities on a repository they have not seen, and the team's owners acquire responsibility for answering for it — its tickets appear on their board, its lines in their room. A grant is a two-sided thing, and only one side was consenting.

So: adding a repository to a team the actor does not own is an OFFER, not a change. Nothing moves until an owner of that team accepts it. Where the actor owns both sides, it takes effect at once — there is nobody to consult, and requiring somebody to accept their own offer would be ceremony rather than consent.

An offer expires after seven days, the same span an account invite holds, and for the same reason: a pending grant that never expires is a grant that lands on whoever inherits the team next. Expiry is silent — an offer nobody answered is a question withdrawn, not a refusal to report.

Declining is not a refusal to record either. It leaves no state: the repository is where it was, and the person who offered may offer again if the answer was "not yet". The alternative — a decline the team can point at — makes a register of who turned down whom, which is a social artifact the forge has no business keeping.

A move is still one operation. Where the repository already sits under another team, the offer carries that: accepting moves it, and the losing team's members are named at the moment of ACCEPTANCE rather than at the moment of offering, because that is when they actually lose access. The watch that reports a change of hands runs on the accept.

action site admin s team owner repo admin a
offer a repository to a team as their role allows takes effect offers
accept or decline an offer only as an owner yes no
withdraw an offer they made as their role allows yes yes

A site admin acts here only through what they own or administer, like anyone (#318).

A team must never reach zero owners. The creator becomes the first owner in the same call that makes the team — otherwise a team is born with none and only a site admin can repair it. Beyond that, the guard that already refuses to leave a repository with no admin applies to teams: a change that would leave a team ownerless is refused, a site admin's included.

Gathering chat

The team has its own room, and that room carries the lines from every repository under it.

Nothing is copied. Every chat message quarry stores already carries the repository it was posted in — chat.Message.Repo, on every line, including bridged and robot lines — so a merged room is a read across rooms in one order, needing no new field and no duplication. Only what is said to the team itself lands in team_chat.

A reply goes back where the line came from. New messages default to the team; replying to a repository's line posts to that repository. A question asked in notify's room should be answered where the people watching notify will see it, not in a room they may not be in.

The team room publishes on a team topic (team-NAME) beside RFC 0011's per-repository topics. Repository lines keep publishing on their own.

Amendment: the team's own lines are a channel, not a table

This RFC's schema block sketched a team_chat table. It was not built, and should not be: a team's own lines live in a chat channel keyed team-NAME, exactly like a repository's, and the merged room is chat.MergeBack over the team's channel and its repositories'.

The reason is that a table would have made team lines second-class against every other message on the forge. The chat plane already carries durability, streaming (Tail), erasure (Purge), sequence bounds, unread counts and the activity robot's log, all keyed by an opaque channel code. In SQLite a team's lines would have had none of those, and somebody would eventually have built each of them a second time for one room. This RFC's own principle above — "nothing is copied… no new field and no duplication" — points at the channel; the schema block simply predated understanding the plane's shape.

Channel codes cannot collide: a repository's is the store's project code, 40 hex characters, and team- is neither.

What this leaves undone, tracked rather than discovered: the merged room does not stream yet — ticket 82b9615eaf3d. Reading is paged with a cursor that carries a position per channel, and a new line appears on the next load. Streaming is the forge's default (RFC 0011), so this is a debt and not a design choice; it is separate only because tailing N channels at once is a real question — one subscription per repository, or a fan-in on the team topic this section already describes — and the same question is open for forum threads, which makes it worth answering once for both rather than twice differently.

The caution, recorded. GitHub built team discussions and retired them — sunset announced February 2023, gone by Enterprise Server 3.12 — consolidating two overlapping discussion products into one. The failure was a second place to talk. A team room that gathers the rooms that already exist is a second place to read, which is the opposite move, and the reply rule above is what keeps it that way.

Gathering tickets

One list across the team's repositories, grouped by assignee, with nobody last and pickable — an unassigned column is what a team opens the page for.

This is not new machinery. ticket.across already answers "every tracker the caller can reach, as one listing, with repository as one more browse axis". The team board is that op scoped to the team's repositories rather than to the caller's granted set, plus an assignee axis. Its 25-repository ceiling becomes paging rather than a cap.

Triage happens in place. Reassigning writes assignee on that repository's own tracker. quarry could not do that until it learned to author every field a tracker defines (ticket 0eafaa1984); before that a board could have shown assignees and not changed them.

Companions follow

A repository's companion forum (RFC 0015) follows it under the team, and is not listed as a repository of its own — the same rule that keeps it off the repositories list and folds its counts into its parent. A team holding fossil says three repositories, not four, and fossil-forum's threads appear in the team's room and search attributed to fossil.

Otherwise the aggregations would have a hole exactly where the forum is.

Public presentation

Teams are public by name. This is a deliberate divergence and the reason is the one-level model: in every surveyed forge a team lives inside an organisation and is never visible to non-members, while the organisation is publicly listable. A quarry team is at the organisation's level, so it takes the organisation's visibility.

The explore tabs gain the third they were written to expect. The comment that holds them at two says why, and it is the acceptance test for this work: "a tab that opens onto an empty page teaches a reader the forge is broken rather than that the feature is unbuilt."

surface teams shown? why
/?view=explore no that tab is repositories
/people no that tab is people
/teams yes, by name the third tab, gated on teams existing
/t/NAME yes, publicly name, title, description, repositories, members
a repository's settings one line naming its team the binding is visible where it applies
Fleet yes, with counts a site admin's view of the whole

The tab renders only when the forge has a team, and its count follows the no-zero rule the repository tabs already follow.

Top-level names are a reserved word list in quarry, not a namespace, so a team page is /t/NAME rather than /NAME. The account-namespace claim in §"The model" is about grant resolution, not URLs.

A description is a separate field from the title. title is the display name — "Release engineering" beside the handle release-eng — and descr is the sentence that says what the team is for. Forgejo and GitLab both carry one, and a directory of bare names is a worse page for the want of it. Public, like the name.

Members are listed publicly by default, and a member may opt out (team_member.listed). Forgejo's rule, and the member list is the useful part of a public team page — hiding it by default would leave the page mostly empty. The opt-out matters because a member did not choose the team's visibility.

What is not proposed

Reversed, 2026-09-14, at dmestas's ask. The decision above asked to read as a decision, so reversing it is written down rather than drifted into.

Two of the three costs it named have since been paid for other reasons. Storage exists: the forge may bind a bucket of its own (oltp.ForgeBucket), which arrived so that attachments would work on a forge whose users have no storage accounts — an uploaded mark is one more small object in a bucket the operator already runs. Serving exists in the same place. What is left is the size policy, which is one constant.

The privacy promise is not waived, it is kept by construction: the forge SERVES the bytes and the page never links the bucket. An <img> pointed at R2 would have broken that promise for every reader of every page a mark appears on, which is the thing the original objection was really protecting.

What is not reversed is the fallback. An identicon derived from the name stays the default and the absence case: a team that uploads nothing looks exactly as it did, and on a forge with no bucket bound — which is every forge until an operator binds one — nothing changes at all. The feature is an addition to the presentation layer and nothing below it depends on it.

Repositories get the same thing from the same mechanism. One table keyed by (kind, name), because a mark is a mark; the kind is half the key rather than a label, since a team and a repository may hold the same name.

Consequences to state plainly

The repository People page changes meaning. It answers from stored strings today, so the moment team grants exist it would silently omit everyone who arrives through a team. It becomes a report of every route in. This is the riskiest change in the feature and it is not one of the new pages.

A team is a lens, not a move. Repositories keep their own chat rooms and trackers. Deleting a team loses the lens and the lines that were only ever said to the team, and nothing else.

Creation is unmetered. Any user may make any number of teams, as on GitHub's free tier and Forgejo. If that becomes a spam surface the answer is a rate limit, not a gate.