Appearance
Contrato de escopo de grupo — telas de negócio (scope=group)
Decisão: ADR 0006 (
docs/adr/0006-group-scoped-reads.md), 2026-07-29. Este documento é o contrato para o time de frontend e o complemento do handoff "Escopo Tenant × Consolidado (GrydScopeIndicator)". O handoff continua válido na arquitetura do shell (dois eixos, capability como gate, indicador no slot central,TenantGuardintocado, token sempre tenant token). O que muda é a camada de dados — §§ 6 e 7 aqui. Base URL/api/v1. Erros emapplication/problem+json.
1. O que "consolidado" é — e o que não é
Consolidado = todo o grupo do tenant do token. O conjunto é o tenant do grupo mais seus filhos ativos (se o token já estiver no tenant do grupo: ele mesmo mais os filhos ativos). O backend resolve esse conjunto a partir do token; o frontend nunca envia ids de tenant.
Isso corrige o handoff, que dizia "Todas as organizações = os vínculos do usuário (caller-scoped)". Vínculos do caller podem cruzar grupos distintos e nesse caso "consolidado" perde o significado de grupo. A semântica de vínculos pode voltar depois como um modo adicional (accessible) — o enum foi desenhado aberto para isso — mas hoje não existe.
Consequência direta no texto da UI: o rótulo do indicador deve falar de grupo, não de "todas as organizações". Sugestão de chave i18n: scope.group.label = "Todo o grupo" / scope.group.sub = "{groupName} · {count} ORGANIZAÇÕES".
E continua valendo, agora com força de decisão (G5): scope=group nunca é visão de plataforma. Mesmo um admin:system numa tela de negócio recebe o grupo do token. Visão plataforma existe só nas rotas /admin/* do Épico 889 — que são administração, não telas de negócio.
2. Como a tela pede o escopo
Query parameter na mesma rota:
GET /api/v1/pendencias?scope=tenant → (default, pode omitir)
GET /api/v1/pendencias?scope=groupNão existe rota gêmea /pendencias/consolidado. O handoff assumia isso no §10.1; a decisão foi por parâmetro na rota existente para que o mecanismo seja transversal — uma tela nova nasce com os dois modos sem nada de novo no backend.
| Valor | Resultado |
|---|---|
| ausente | tenant |
tenant | escopo do tenant do token (comportamento atual) |
group | grupo do token, se a rota declarar que serve grupo e se autorizado |
| qualquer outro | 400 SCOPE_INVALID |
Comparação é case-insensitive e o valor é trimado — ?scope=%20GROUP funciona.
A rota precisa declarar que aceita. Um endpoint anuncia os modos que serve com [ScopeCapable(...)], e o Swagger daquele endpoint mostra scope com o enum exato dele. Pedir group a uma rota que não declarou é 400 SCOPE_INVALID, não 403: é um fato sobre a rota, não sobre os direitos de quem chamou — a recusa não diz se existe grupo nem se você poderia lê-lo. Na prática o front nunca deve chegar nesse 400: quem decide é a capability do §3.
3. Capabilities — vocabulário unificado
O GET /api/v1/auth/session passa a usar um vocabulário para escopo: none | tenant | group | global. O bloco anuncia, por domínio, o conjunto permitido e o default:
json
{
"capabilities": {
"users": { "scopes": ["global"], "default": "global" },
"tenants": { "scopes": ["tenant"], "default": "tenant" },
"pendencias": { "scopes": ["tenant", "group"], "default": "tenant" },
"financeiro": { "scopes": ["tenant"], "default": "tenant" }
}
}Um domínio que a sessão não alcança é ausente do mapa — nunca scopes: [] nem "none" (US 4.3). Leia com encadeamento opcional: capabilities[dom]?.scopes ?? [].
Três mudanças em relação ao que está escrito hoje:
consolidatednão existe — o valor égroup. O handoff propôsconsolidated|tenant|none; o Épico 889 já havia emitidoglobal|tenant|nonepara users eglobal|ownpara tenants. Eram três dicionários para o mesmo conceito e nenhum tinha consumidor ainda; ficou um só.ownviroutenante oglobalde users/tenants continuaglobal(é plataforma, e é coisa diferente degroup).scopesingular virouscopes+default. Anunciar o conjunto — e não o máximo — é o que permite a rotabothdo shell degradar certo: comscopes: ["tenant"]o indicador simplesmente não aparece e o seletor não oferece a opção de grupo, sem o front precisar inferir nada.
O roteamento do §2 do handoff vira:
ts
const caps = (await api.get("/auth/session")).data.capabilities["pendencias"];
const canGroup = caps?.scopes.includes("group") ?? false;
const scope = effectiveScope.mode === "group" && canGroup ? "group" : "tenant";
// mesma rota, sempre:
api.get("/pendencias", { params: { ...filters, scope } });Continua valendo o "Tell, Don't Ask": o front nunca olha permissions.
4. Quem enxerga o modo grupo
Server-side, e são duas permissões — porque somar não é o mesmo ato que ler as linhas das empresas irmãs:
| Permissão | Libera |
|---|---|
read:group-analytics (já existia no catálogo) | sumários/agregados do grupo: totais, contagens, breakdown por organização |
read:group-data (nova) | listagem de registros de outros tenants do grupo |
Ambas são avaliadas no tenant do grupo — quem lê os dados do grupo tem papel no grupo (a role GroupAdmin recebe as duas no provisionamento). Um Admin de uma filha não passa a ver as irmãs.
Efeito prático para o front: um mesmo domínio pode ter scopes: ["tenant","group"] para o card de sumário e ficar só em tenant para a tabela. Se o seu domínio usar as duas coisas na mesma tela, trate-as como duas capabilities (ex. pendencias.summary e pendencias) e peça o par ao backend do domínio.
5. Erros
| Situação | HTTP | code | O que o front faz |
|---|---|---|---|
scope desconhecido | 400 | SCOPE_INVALID | bug de cliente — reportar |
| token sem grupo (tenant fora de hierarquia) | 422 | SCOPE_GROUP_UNAVAILABLE | não deveria acontecer: a capability não teria anunciado group. Degrada para tenant e reporta |
| sem a permissão do modo | 403 | SCOPE_NOT_AUTHORISED | idem — a capability lê o mesmo direito, no tenant do grupo, que este gate avalia; só uma revogação entre o /session e a chamada chega aqui. Degradar para tenant + re-GET /auth/session |
| registro fora do escopo | 404 | anti-disclosure | "não existe", nunca "sem acesso" |
| invalidação de token | 401 | — | refresh → re-/session → re-derivar capabilities (o escopo pode ter mudado) |
O caminho de degradação silenciosa do handoff (§6, perda de capability) continua certo e agora tem os códigos acima como gatilho secundário.
5.1 Forma da resposta
Os três saem em application/problem+json com code, detail, traceId e o mesmo type:
jsonc
{
"type": "https://gryd.io/errors/scope-error",
"title": "Requisição Inválida",
"status": 400,
"detail": "Escopo desconhecido. Valores aceitos: tenant, group.",
"instance":"/api/v1/pendencias",
"code": "SCOPE_INVALID",
"traceId": "…"
}Duas garantias para o front:
- Ramifique pelo
code, nunca pelodetail.titleedetailsão localizados (pt-BR, en, es-ES) e seguem a cultura negociada na requisição — são texto para humano, não contrato.codeetypesão estáveis. typeéscope-errornos três, inclusive no 403. Uma recusa de escopo é distinguível de uma recusa de autorização genérica sem olhar ocode.
O detail do 400 lista os valores aceitos; o do 403 não nomeia a permissão que faltou nem nenhum tenant do grupo; o do 422 não nomeia o tenant do token.
6. Payloads
6.1 Sumário / agregado
Uma chamada devolve o total e o detalhamento por organização — não peça N vezes, um por tenant:
jsonc
{
"scope": { "mode": "group", "groupId": "…", "groupName": "Grupo Acme", "tenantCount": 7 },
"total": { "pending": 4812, "overdue": 311 },
"byTenant": [
{ "tenantId": "…", "tenantName": "Acme SP", "pending": 1204, "overdue": 88 },
{ "tenantId": "…", "tenantName": "Acme RS", "pending": 902, "overdue": 41 }
]
}byTenant existe justamente para alimentar a exigência do design (§10.3 do handoff): em escopo consolidado o dado tem que dizer de onde veio.
Garantias do bloco scope, iguais nos dois payloads:
| Campo | Em mode: "tenant" | Em mode: "group" |
|---|---|---|
mode | "tenant" | "group" — string, nunca número |
groupId / groupName | null | o tenant do grupo e o nome dele |
tenantCount | 1 | tamanho do conjunto (pode ser 0: grupo sem filha ativa devolve zero linha, e continua sendo group) |
truncated | ausente | ausente, ou true se a leitura foi cortada |
Sobre truncated (US 5.3): o backend limita quantos tenants uma leitura de grupo pode tocar (MultiTenancyOptions.MaxGroupScopeTenants, default 200). O limite é folgado de propósito — a hierarquia de um nível deixa um grupo em dezenas de empresas —, então na prática o campo nunca aparece. Quando aparece:
- ele vem
true, nuncafalse: leitura completa omite o campo. Trate a presença como o sinal, em vez de comparar com um default; tenantCountpassa a ser o limite aplicado, não o tamanho do grupo. Os dois números só coincidem quandotruncatedestá ausente;- o resultado é parcial e a UI precisa dizer isso. Um total consolidado que não cobriu o grupo inteiro, exibido como se tivesse coberto, é a pior forma de errar um número que vai alimentar decisão de gestão;
- o tenant do grupo está sempre no conjunto cortado; o corte é determinístico, então duas páginas seguidas concordam sobre de quais empresas vieram.
byTenant vem ordenado por nome do tenant, com desempate por tenantId — duas organizações de mesmo nome (ou ambas sem nome) não fazem o snapshot da UI oscilar entre execuções. A ordenação é aplicada pelo backend em todos os domínios, não é responsabilidade da tela.
6.2 Listagem de registros
Mesmo envelope PagedResult<T> do modo tenant — mesmos filtros, mesma ordenação, mesma paginação — com dois acréscimos:
- cada item carrega
tenantIdetenantName(a coluna/legenda de organização passa a ser obrigatória na tabela em modo grupo); - a resposta carrega o mesmo bloco
scopedo §6.1.
A forma escolhida é campo no envelope, não envelope novo — vale para todos os domínios:
jsonc
{
"items": [ { "id": "…", "tenantId": "…", "tenantName": "Acme SP", "…": "…" } ],
"totalCount": 4812, "pageNumber": 1, "pageSize": 20,
"totalPages": 241, "hasNext": true, "hasPrevious": false,
"scope": { "mode": "group", "groupId": "…", "groupName": "Grupo Acme", "tenantCount": 7 }
}scope é ausente/null em endpoint que não é scope-capable, que hoje é a maioria: uma rota que nunca ofereceu escolha de escopo não responde como se tivesse feito uma. Não trate a ausência como erro — trate como "esta rota tem um modo só".
Aviso de performance para combinar com o time de backend por domínio: paginação profunda em modo grupo pode migrar para cursor em domínios de volume alto. Se isso acontecer no seu domínio, o contrato de paginação daquele endpoint muda e será documentado no próprio domínio — não presuma page/pageSize eternos em modo grupo.
7. Query keys
Confirmado e obrigatório (era o §10.2 do handoff, com o vocabulário ajustado):
ts
// tenant: ['pendencias', 'tenant', tenantId, filters]
// grupo: ['pendencias', 'group', groupId, filters]O groupId na key (e não uma string fixa) evita cache cruzado quando o usuário faz switch-tenant para um tenant de outro grupo.
8. O que permanece exatamente como no handoff
Nada abaixo muda — é bom que esteja escrito para ninguém reabrir:
- token é sempre tenant token; escopo não é claim, não é header, não é re-autenticação. O
api-clientnão muda; - sempre existe tenant ativo;
TenantGuardintocado; rota de grupo não redireciona para seleção de tenant; - escopo é apresentação/navegação, autorização é do backend;
RouteScopeCapability(tenant|global|both) do shell segue sendo declaração de rota — só troque o nome do valorglobalporgrouppara não colidir comglobal= plataforma no vocabulário de capabilities;- indicador nunca fica invisível, colapsa no mobile; sem deslocamento de layout;
aria-live; SSOT de breakpoints; tokens nogryd-tokens.css; - fatias 1-3 do plano do front seguem desbloqueadas com capability mockada — agora com o formato do §3 acima. A fatia 4 depende da fatia 4 do backend (domínio de referência).