- Feature Name:
teams(a user-owned place that holds repositories, carries grants, and gathers their chat and tickets into one room) - Start Date: 2026-09-12
- Status: Draft. Amended after acceptance: see §"
team_repodecides the path segment", which settles how a team's name reaches a URL. That question was raised by the namespaced-paths work and was not answerable when this RFC was written. - Depends on: RFC 0008, which deferred this and named the ticket; RFC 0004 (capability letters are the native model); RFC 0017 (the bar for a write scales with the tier) for the companion half of the membership floor; RFC 0015 (companions follow their parent); RFC 0001 §8, which parks org/ACLs in v2 as "tables, not architecture"; RFC 0011 §topics (a team room needs one)
- Qualifies: RFC 0008's claim that "nothing below needs changing to add one". Two things below do. See §"Where 0008 holds and where it does not".
- Evidence: a survey of GitHub, Forgejo/Codeberg, GitLab, Bitbucket Cloud, sourcehut and Fossil, read from vendor documentation. The convergent findings are taken as given and cited where they decide something.
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
);
CREATE TABLE team_chat (
team TEXT NOT NULL, seq INTEGER NOT NULL,
account TEXT NOT NULL, text TEXT NOT NULL, at TEXT NOT NULL,
PRIMARY KEY (team, seq)
);
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_repois the authority for which team holds a repository.repo.namespacesays 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:
- A path segment is not a place to express an exception. Because a disagreement is always restored, a repository's segment cannot be set to differ deliberately from the team holding it. Nothing here asks for that, and it is now a rule rather than a coincidence.
- Dissolving a team is a path change for every repository it held, and must say so where it is offered — the same obligation RFC 0017 puts on unbinding a companion. A control that silently rewrites a dozen URLs is the kind of thing people discover from a broken link.
- A placement record exists exactly when somebody placed it.
namespace_bynames who put a repository where it is, so it is empty precisely when nobody did: a segment that fell back to the owner is where a repository lands when no choice applies, not a choice somebody made. Anything reading that field MUST NOT treat empty as "unknown" — it is the answer, not a gap in the record.
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:
- the repository's categories (RFC 0004, unchanged),
- their own stored string (unchanged),
- the membership floor, if that repository is under a team they are in,
- 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 | yes | yes | no |
| set title, members, roles | yes | yes | no |
| put a repository under the team | yes | only if they hold a on it |
yes |
| take it out | yes | yes | yes |
Creation is any user's, so the first column is a site admin's ability to act, not a gate.
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.
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, and a site admin is told instead.
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.
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
- An organisation level above teams. Every surveyed forge has one, and quarry may want one. Recorded as possible and not proposed: if it arrives, "team" narrows to mean what it means elsewhere, and that renaming is the cost of adding it later rather than now.
- Nesting. Forgejo has none and does not miss it; GitLab permits twenty levels and advises five because permission calculation on deep trees times out. A flat team is the whole of what is wanted.
- Registry ownership. A team manages access to repositories; it does not own them in the registry. Deleting a team must not be able to delete anybody's code.
- A team restricting anything. Every tier adds.
- Teams owning teams, or a team as a grant target for another team.
- Private or hidden teams. The name is claimed from the shared namespace whether or not the team is visible, so hiding one buys less than it appears to; and the one-level model puts teams at the level every forge makes public.
- Aggregating anything else. Chat and tickets are what was asked for. Landings, wikis and CI across a team are not proposed.
- An uploaded avatar. Forgejo and GitLab both offer one. quarry draws identicons locally and its privacy page promises the interface loads nothing from another host; an uploaded team avatar means storage, serving and a size policy for a cosmetic gain. An identicon derived from the team's name is free and is already how people are drawn. Recorded because the absence should read as a decision.
- A profile README. GitHub renders one from a public
.githubrepository, Forgejo from a.profilerepository — both hang it off a repository inside the organisation. A quarry team holds repositories but owns none of them in the registry sense, so there is no repository it could claim by convention without contradicting that. A team README is not refused on merit; it needs a home this model does not give it, and inventing one for prose is the wrong order.
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.