Compare commits

..
Author SHA1 Message Date
fred 501112d2a4 docs: RBAC grant model contract revision 2 (GLM review findings 1-10)
ci/woodpecker/pr/ci Pipeline was successful
- name the two shipped platform-admin bypass paths (command-authorization
  hasScope short-circuit, MCP tenant-admin scope derivation) as
  non-conformant; 7.4 retires them explicitly
- suspend team grant subjects pending a team contract; command surface
  refuses team-subject grants; evaluation semantics pre-specified
- no-self-escalation restated with future-decoupling rationale; observable
  is an audit invariant, not an unconstructible refusal test
- bootstrap seeding scoped to top-level company creation; child nodes
  inherit via down-the-chain, resolving the 7.7/4.3 contradiction
- exact A1 quote (not a bypass of workspace authorization)
- subject/role audit fields attributed as this contract's addition
- principal-position consequence named (1.3) with matrix cell
- added observables: zero membership rows, fail-closed fault injection,
  no existence oracle
- role-string namespacing rule (4.5)
- ruling request names the PRD-admins interpretive resolution
2026-08-26 18:33:08 -05:00
fred 3538d33106 docs: RBAC grant model contract (S2 contract 2)
ci/woodpecker/pr/ci Pipeline is pending
Defines the hierarchy grant vocabulary (viewer|member|owner, totally
ordered), evaluation semantics (deny-by-default, down-the-chain, max rule,
live fail-closed evaluation), grant management (owner-managed, no
self-escalation, explicit bootstrap of authority), transfer authority
(both-sides owner, completing contract 1 §4.2), and revocation propagation
with the same 30s/next-message bound as identity §7.1.

Embedded decision for ratification: platform admin confers no implicit
tenant access (§1.1).

Sources: PRD Part I §4, native-kanban SOT Amendment A1 §8.1.3/§8.3,
hierarchy-schema contract (PR #1435), identity-lifecycle contract §1.4
(PR #1433).
2026-08-26 18:23:23 -05:00
2 changed files with 259 additions and 380 deletions
-380
View File
@@ -1,380 +0,0 @@
# Hierarchy Schema Contract (D2)
Status: DRAFT — awaiting ratification (webui-audit S2, contract 1 of 9).
Authority: PRD D2/D9/D13 (Part I §4) and the native-kanban SOT Amendment A1
(`docs/requirements/native-kanban-sot.md` §8, ratified 2026-08-25). This
document turns the ratified hierarchy into a concrete schema contract:
tables, cardinalities, constraints, and ownership/transfer semantics. It is
the prerequisite for the hierarchy command family and for the RBAC grant
model (contract 2, `docs/requirements/rbac-grant-model.md`).
Revision 2 (independent review, GPT-5.6 terra): tenancy-FK exemption made
explicit (§1.1); record class extended to include `hierarchy_grants`
(§1.1); provenance corrections on legacy tables and the planning `projects`
table (§1.3, §2 naming note); NOT NULL and `NULLS NOT DISTINCT` grant
uniqueness (§2.6, §3.2); grant FK delete actions split cascade/restrict
(§3.3); transfer transaction includes its audit write (§4.3); ownership
invariant completed via contract 2 with the both-sides rule marked as new
policy (§4.2, §4.4); hierarchy audit brought under REQ-AUD-001-equivalent
guarantees with deletion-safe linkage (§5.2); roll-up never-a-write restored
to full A1 strength (§5.4); §6 rebuilt with bounded observables for every
MUST (allowlist, command surface, audit, corrected cardinality witness).
Revision 3 (terra re-review residuals): §4.3 transfer write inventory
reconciled with §5.2 — the transaction's writes are the single class-row
mutation plus that mutation's §5.2 audit writes (event + outbox record),
not "exactly two writes"; §6.3 extended with a closed writer-coverage
witness so an unregistered internal writer cannot pass a registered-route
inventory. (Terra's finding-8 residual — a stale contract 2 §7.8 backlink
to contract 1 §6.2 — was already fixed in contract 2 revision 2, which
cites §6.5; measured against `origin/contract/rbac-grants` head
`501112d2`.)
Revision 4 (terra r3 residual F7): the §6.3(b) writer-coverage assertion
extended to raw SQL — it now also fails on class-table name literals
inside SQL strings or tagged SQL templates outside the allowlist, so a
raw-SQL writer that touches no schema symbol is still caught.
Revision 5 (terra r4 residual F7): §6.3(b) gains a third prong — any
raw-SQL execution primitive outside the allowlist fails the assertion
regardless of its SQL content, closing the evasion where a
dynamically constructed table name carries neither a schema symbol nor
a class-table literal. The detection claim is now coextensive with
what the three prongs statically see.
Revision 6 (terra r5 residual F7 + new F8): the "two prongs" wording
corrected to three (F8); §6.3(b) gains the allowlist composition rules
(no generic raw-SQL helper is allowlisted; an allowlisted module may
not export caller-supplied-SQL execution) and fails outright on
runtime code-construction primitives; the detection claim is scoped
honestly to the stated syntactic forms, with evasions beyond static
reach assigned to §5.1 review/audit rather than claimed for CI.
Revision 7 (terra r6 new F9): the false-positive remedy no longer
contradicts the composition rules — legitimate non-hierarchy raw
execution (e.g. the db package's migration runner) is dispositioned
onto a second closed enumerated list, the infrastructure register,
exempt from prong (iii) only, still bound by prongs (i)/(ii), barred
from the writer allowlist, and importable only by registered modules
or the operational entry points.
Revision 8 (terra r7 residual F9): the register's import rule made
satisfiable by the live tree — imports are checked re-export-aware
(package barrels followed), and each registered module carries its own
closed importer enumeration, which may name operational entry points
such as the Gateway's startup migration hook; named importers stay
subject to prongs (i)/(ii) and gain no writer standing.
Revision 9 (terra r8 F10): revision 8 called the Gateway database
module the runner's "one live importer today". That was false — the
measured production importer set has four members. The enumeration
example now lists the complete measured set, and the import analysis
is extended to resolve literal dynamic `import()` routes, which two of
the four members use.
Scope: the tenancy/authorization structure record class — companies,
estates, platform-projects, workspaces, hierarchy grants, their parentage,
and constraints. Out of scope: the RBAC grant vocabulary and evaluation
semantics (contract 2), roll-up projection semantics (contract 8), kanban
planning entities inside workspaces (SOT §5), migration or retirement of
legacy flat data (future work; see §1.3).
## 1. Record class and placement
1. The **tenancy/authorization structure record class** defined by
Amendment A1 §8.1.2 comprises five tables: the four node tables of §2
AND `hierarchy_grants` (§3) — A1 includes hierarchy-level access grants
in the class. Every rule addressed to "the class" in this contract
(payload prohibition, mutation path, audit) binds all five tables. Class
rows carry parentage, naming, grant, and audit-linkage data only — never
task, plan, or any business/orchestration payload.
References from business/orchestration rows into the class are limited
to exactly one form: the canonical `workspace_id` tenancy column that
REQ-TEN-001 requires on every canonical row, referencing
`workspaces.id`. No business/orchestration row may reference a company,
estate, platform-project, or grant id in any position, and no
business/orchestration row may reference a workspace id in any
non-tenancy position (dependency, claim target, work subject).
2. Hierarchy records are NOT workspace-scoped rows: REQ-TEN-001's
`workspace_id` obligation binds business/orchestration rows and does not
apply to this class (A1 §8.1.2). The `workspaces` table itself is the
anchor the obligation points at.
3. The legacy flat tables (`teams`, and the Brain planning `projects` table
in `packages/db/src/schema.ts`) are not part of this class. What A1
§8.1.4 pins is narrower: the planning `projects` table and
`platform_projects` stay distinct tables. This contract adds, as new
policy ratified here: neither `teams` nor `projects` is repurposed as a
hierarchy table. Their eventual migration or retirement is future work
that no existing REQ assigns; it is out of scope here.
## 2. Tables and cardinalities
Naming: the level above workspaces is `platform_projects`, per A1 §8.1.4.
The existing `projects` table is Brain planning data (so labeled in
`packages/db/src/schema.ts`; it carries no `workspace_id`), and the schema
MUST NOT merge the two. (A rename of either remains an implementation-PR
decision under A1; this contract pins only that they stay distinct tables.)
1. `companies` — id (uuid pk), name, slug (unique per deployment),
created_at, updated_at. N per deployment (D2).
2. `estates` — id, name, slug, `company_id` NOT NULL →
`companies.id` ON DELETE RESTRICT. Exactly one company per estate; a
company holds any number of estates.
3. `platform_projects` — id, name, slug, `estate_id` NOT NULL →
`estates.id` ON DELETE RESTRICT. Exactly one estate per
platform-project; an estate holds any number of platform-projects.
4. `workspaces` — id, name, slug, `platform_project_id` NOT NULL →
`platform_projects.id` ON DELETE RESTRICT. Exactly one platform-project
per workspace. This table is the referent of every `workspace_id` column
the SOT requires on canonical rows.
5. **Chain resolution is by construction.** Because every parent FK is NOT
NULL and single-valued (one FK column, no parentage edge tables, no
multi-parent forms, no nullable "detached" states), each workspace
resolves to exactly one platform-project → estate → company chain (A1
§8.3 acceptance 1). One-parent-per-child is the constrained direction;
many children per parent is valid data.
6. **Slug scoping.** All `name` and `slug` columns are NOT NULL.
`estates.slug` is unique within its company, `platform_projects.slug`
within its estate, `workspaces.slug` within its platform-project
(composite unique constraints). Display names are unconstrained beyond
NOT NULL.
7. No hierarchy table carries a `metadata` jsonb column or any
free-form payload field. The columns declared in this section and §3
are exhaustive: a class table's column set is exactly its declared set
(verified per §6.2) — nothing else (A1 §8.1.2).
## 3. Grant attachment points
The grant vocabulary (which roles exist, what each permits, how evaluation
and revocation work) is contract 2. This contract pins only the schema
shape contract 2 attaches to:
1. `hierarchy_grants` — id, subject (exactly one of `user_id``users.id`,
`team_id``teams.id`; CHECK-enforced exactly-one-of), target (exactly
one of `company_id`, `estate_id`, `platform_project_id`;
CHECK-enforced exactly-one-of), `role` (text NOT NULL; vocabulary and
its CHECK constraint owned by contract 2 §2), `granted_by` NOT NULL →
`users.id`, created_at.
2. Uniqueness: at most one grant row per (subject, target, role). Because
the subject and target columns are nullable by design, ordinary
PostgreSQL composite uniqueness treats NULLs as distinct and would not
enforce this. The implementation MUST use a single
`UNIQUE NULLS NOT DISTINCT` constraint across (`user_id`, `team_id`,
`company_id`, `estate_id`, `platform_project_id`, `role`) or six
equivalent partial unique indexes (one per subject×target form). The
pinned Drizzle ORM supports `nullsNotDistinct()`.
3. Delete actions are split by column class:
- Target FKs (`company_id`, `estate_id`, `platform_project_id`):
ON DELETE CASCADE — the one permitted cascade in this class. A grant
on a deleted node is meaningless and fail-open if retained. Cascaded
grant deletions are audited per §5.2.
- Principal FKs (`user_id`, `team_id`, `granted_by`): ON DELETE
RESTRICT. The identity contract (§7.3) gates user deletion today and
defines no team-deletion rule; this contract does not invent one.
These FKs stay RESTRICT until an explicit deletion-and-retention
contract ratifies otherwise.
4. Workspace-level access is evaluated, not stored here: a grant at any of
the three levels evaluates down the chain to workspace-scoped
authorization (A1 §8.1.3). No `workspace_id` column exists on
`hierarchy_grants` — workspace membership (REQ-ID-001) remains its own
mechanism inside the SOT schema, and the chain adds where grants can be
declared, never a bypass.
## 4. Ownership and transfer
"Assets are transferable subject to the structure" (PRD Part I §4):
1. A transfer changes exactly one parent FK on exactly one hierarchy row:
workspace → new platform-project, platform-project → new estate, estate
→ new company. Nothing else in the class or the SOT changes: business
and orchestration rows inside affected workspaces are untouched, keep
their `workspace_id`, and never cross a workspace boundary (A1 §8.1.3
"chain maintenance").
2. Transfer authorization requires authority over BOTH the source and the
destination parent. This both-sides predicate is **new policy
introduced by this contract pair** (D2/A1 do not state it); its
evaluation semantics are contract 2 §5. The structural half — that the
transfer command evaluates it before mutating — binds here.
3. A transfer transaction mutates exactly one class-table row — the
single-row parent-FK update — and contains, beyond that, only the
§5.2 audit writes for that mutation (the audit event and its
hierarchy-outbox record, committing in the same transaction). No other
class, business, or orchestration row changes. There are no multi-row
transfer batches at the schema level; bulk moves are N audited
transfers.
4. Hierarchy records have no `owner_id`. Ownership in the hierarchy IS the
grant structure: a "company owner" is a subject with an `owner` grant
on that company or an ancestor (contract 2 §2), not a column. The
ownership invariant across the contract pair: a node may hold zero
direct owner grants (authority can derive from an ancestor grant); node
creation names the initial `owner` grant in the same audited operation
and the wizard seeds the first company's owner the same way (contract 2
§4.3); transfer and revocation semantics are contract 2 §§56. This
avoids column-encoded authority of the kind the legacy schema carries
(`teams.owner_id` and `teams.manager_id` are required user FKs, and
`team_members.role` is a further authority field — none of them
evaluable under a grant model).
## 5. Mutation path, audit, and deletion
1. All hierarchy mutations flow through the same sole-writable-SOT,
fail-closed, audited Gateway command path as everything else (A1 §8.2.3,
REQ-API-001). No direct-DB writers, no raw CRUD endpoints.
2. **Audit parity.** A1 §8.2 leaves every pre-existing REQ binding, so
hierarchy mutations get REQ-AUD-001's guarantees, not a weakened
substitute. Concretely:
- Every create, rename, transfer, grant create/change/revoke, and
delete — including every grant deletion cascaded by a node delete —
emits a semantic audit event carrying actor, verb, target, and (for
transfers) source and destination parents, with the correlation,
causation, idempotency, and per-target ordering guarantees REQ-AUD-001
defines.
- The state change and its audit event(s) commit in the same
transaction, delivered through a transactional outbox. Hierarchy
events are not workspace-scoped rows and do not ride the workspace
outbox; they get an equivalent hierarchy outbox under the same
append-only, same-transaction rules.
- **Deletion-safe linkage:** audit events reference their target by an
immutable snapshot (id, slug, and parent chain at event time), never
by a foreign key into the class tables, so append-only events survive
the deletion of their target.
3. Deletion is fail-closed bottom-up: a hierarchy record with children
cannot be deleted (RESTRICT FKs, §2). Deleting a workspace is a SOT-side
operation subject to the kanban SOT's own rules and is not granted any
new semantics by this contract.
4. **Roll-up is never a write** (A1 §8.2.2, preserved at full strength). A
roll-up read mutates nothing — not hierarchy state, and not business or
orchestration state: it must not mutate, claim, order, or gate
workspace work. Contract 8 owns projection details but cannot narrow
this rule. This contract additionally guarantees the chain roll-ups
aggregate over is unique and non-null (§2.5).
## 6. Verification requirements
Binding on the implementing PRs (extends A1 §8.3):
1. Schema witnesses (real PostgreSQL, §6.8): chain construction — insert
with a null parent FK refused; insert with one valid parent accepted;
two siblings under one parent accepted (the control proving the
constraint rejects only what §2.5 forbids); catalog assertion that each
child table has exactly one parent-FK column and no parentage edge
table exists. Composite slug uniqueness per parent (duplicate slug
under same parent refused; same slug under different parents accepted).
Grant CHECKs: exactly-one-of subject and exactly-one-of target each
witnessed (zero and two set → refused). Grant uniqueness: a duplicate
(subject, target, role) row refused for each of the six subject×target
forms, proving NULLS-NOT-DISTINCT semantics; NOT NULL on `role`,
`granted_by`, and all `name`/`slug` columns witnessed.
2. Column allowlist: an information_schema assertion that each class
table's column set is exactly the set declared in §2/§3 — the bounded
observable for no-payload (§2.7) and no-`owner_id` (§4.4).
3. Command surface: two witnesses, both required (§5.1). (a) Route
inventory: an assertion over the Gateway's registered hierarchy
routes/commands proving the registered mutation surface is exactly the
declared hierarchy command family — no generic CRUD endpoint. (b)
Writer coverage — the closed allowlist a route inventory cannot
provide: a static CI assertion over the Gateway and package sources
with three prongs, each bound to one explicitly enumerated allowlist
of hierarchy command/repository modules. (i) Symbol prong: write
references to the class-table schema symbols (insert, update, delete)
occur only in allowlisted modules. (ii) Literal prong: a class-table
name appearing inside a SQL string or tagged SQL template outside the
allowlist fails the assertion — this is what catches a raw-SQL writer
that references no schema symbol. (iii) Raw-execution prong: any call
to a raw-SQL execution primitive (the ORM's raw/unsafe constructors,
driver-level query/execute) outside the allowlist fails the
assertion, regardless of what the SQL string contains or how it is
constructed — the call site is statically detectable even when a
dynamically assembled table name is not, so a raw writer with a
runtime-built identifier is caught by its primitive, not its
payload. Two composition rules keep prong (iii) meaningful: the
allowlist names hierarchy command/repository modules only — a
generic raw-SQL helper or database-utility module is never
allowlisted; and an allowlisted module MUST NOT export a function
that executes caller-supplied SQL (such an export is itself a
raw-execution primitive, and the exporting module is treated as
unallowlisted for prong (iii) if it does). Legitimate raw execution
that is not a hierarchy writer — e.g. the migration runner in the
db package — lives on a second, separately enumerated
**infrastructure register**, distinct from the writer allowlist and
equally closed. A registered module is exempt from prong (iii) only:
prongs (i) and (ii) apply to it with no exemption, so it can hold no
class-table schema symbol or class-table SQL literal, and it can
never appear on the writer allowlist. To close the laundering path,
the same assertion checks imports, and the import analysis is
**re-export-aware**: it follows package barrels and re-exports, so a
route hidden behind an index module is still a route — and it
resolves literal dynamic imports the same way: an
`await import('<literal specifier>')` is an import edge like any
static import, not an evasion of the analysis (a dynamic import of
the db package whose specifier is not a literal fails the assertion
outright, because it makes the import graph unanalyzable). A
registered module may be imported only by other registered modules
or by importers named on that module's own closed importer
enumeration in the register — operational entry points such as the
migration/bootstrap CLI or the Gateway's startup migration hook.
The enumeration names the complete permitted production consumer
set, and completeness is measured, not asserted: the migration
runner's measured production importer set today has four members —
the Gateway database module (reached through the db package
barrel), the storage package's Postgres adapter, and two mosaic CLI
commands, the fleet-backlog command and the gateway verify command,
both routed through literal dynamic imports of the db package — so
its enumeration names those four. A module that only receives the
runner's functions by parameter injection (the gateway schema-check
module takes them as arguments from the verify command) has no
import edge of its own and is not enumerated. Any import route
outside the enumeration fails the assertion. Being a
named importer confers nothing else: the importer stays fully
subject to prongs (i) and (ii), gains no writer-allowlist standing,
and whether it uses the registered module beyond its operational
purpose is a §5.1 review question, not a static claim. Runtime code-construction
primitives (`eval`, `new Function`) anywhere in the scanned sources
fail the assertion outright, allowlist or not. Schema definitions
and generated migrations are excluded from the literal prong; a
false positive is resolved in the same PR by adding the module to
the one enumerated list its role permits — the writer allowlist for
a hierarchy command/repository module, the infrastructure register
for non-hierarchy raw execution — never by weakening the assertion,
and neither list may take a module the composition rules bar from
it. Both lists are closed, and the assertion's detection
claim is exactly its prongs: it statically surfaces every writer
expressed as a schema-symbol reference, a class-table SQL literal, a
raw-execution call site, or runtime code construction. An evasion
engineered outside those syntactic forms is a §5.1 violation that
review and audit own — the witness does not claim to catch what
static analysis cannot see, and any such evasion found later is
corrected as a conformance defect, not grandfathered.
4. Audit witnesses: for each mutation class (create, rename, transfer,
grant create/change/revoke, delete) — the event exists after commit
with actor/verb/target and same-transaction atomicity; a rolled-back
mutation leaves no event (rollback witness); a node delete's cascaded
grant deletions are each covered by events; events survive deletion of
their target (query the events of a deleted node).
5. Transfer tests: parent-FK update moves the subtree resolution and
modifies zero business/orchestration rows (row-count and content
assertions on workspace contents before/after); transfer without
authority on the source or on the destination side is refused (with
contract 2 §7.8).
6. Deletion tests: delete with children refused at the database level;
delete of a leaf cascades its grants and nothing else; deleting a user
or team that is a grant subject (or `granted_by` referent) is refused
(RESTRICT witnesses for §3.3).
7. Negative tests: no business/orchestration table accepts a company,
estate, platform-project, or grant id in any reference position, and
none accepts a workspace id in any non-tenancy position; the canonical
tenancy FK control — a business row inserted with a valid
`workspace_id` succeeds, with an invalid one is refused; roll-up
endpoints mutate no canonical state anywhere (assert zero writes across
hierarchy AND workspace tables, not hierarchy only); readers see
aggregates only over workspaces they are authorized on, with no
cross-tenant existence oracles (A1 §8.3 acceptance 3).
8. Real-PostgreSQL coverage for every constraint witness (unique/CHECK/
RESTRICT/NULLS NOT DISTINCT behavior), using the `ci-postgres` service
in the `test` CI step; mocked specs cannot witness database constraints.
## Ruling request
Ratify sections 16 as written, with one decision embedded: hierarchy
records carry no owner column — ownership is expressed solely through
grants (§4.4) — say "agreed" or name the ownership model you want.
+259
View File
@@ -0,0 +1,259 @@
# RBAC Grant Model Contract
Status: DRAFT — awaiting ratification (webui-audit S2, contract 2 of 9).
Authority: PRD Part I §4 ("Granular RBAC: admins restrict access per company,
estate, and project; grants are evaluated down the chain") and the
native-kanban SOT Amendment A1 (§8.1.3 RBAC evaluation, §8.3 acceptance 2).
This document defines the grant vocabulary, evaluation semantics, and
revocation propagation that the hierarchy schema contract
(`docs/requirements/hierarchy-schema.md`, contract 1) attaches to. Contract 1
pins the `hierarchy_grants` table shape and defers the `role` vocabulary and
the meaning of "authority" here; the identity contract
(`docs/requirements/identity-lifecycle.md` §1.4) pins that account creation
grants nothing.
Revision 2 (independent review, GLM 5.3): §1.1 consequence analysis
completed — the two existing platform-admin bypass code paths are named as
non-conformant and §7.4 retires them; team grant subjects suspended pending
a team contract (§1.4, §3.33.4, §7.5); no-self-escalation restated with
its true rationale and a constructible observable (§4.2, §7.7);
node-creation seeding scoped to the bootstrap path, resolving the §7.7/§4.3
contradiction; A1 quotation corrected; audit-field provenance corrected;
principal-position consequence named (§1.3); membership-row,
fail-closed-fault, and existence-oracle observables added (§7);
role-string namespacing rule added (§4.5); ruling request now names the
interpretive resolution of PRD "admins".
Scope: the roles that can appear in `hierarchy_grants.role`, what a grant at
each hierarchy level confers, how grants evaluate down the chain, how
revocation propagates, and who may manage grants. Out of scope: the hierarchy
tables themselves (contract 1), workspace-internal membership and its
role/capability vocabulary (native-kanban SOT REQ-ID-001 and its implementing
schema), roll-up projection semantics (contract 8), wizard seeding
(contract 3), the team model (suspended here; see §1.4).
## 1. Three authority layers, none substitutable
1. **Platform role** (`users.role`, better-auth: `member` | `admin`) governs
instance administration — user management, system settings, provider
configuration. It is not tenancy authority: holding platform `admin`
confers **no implicit hierarchy grant and no workspace authorization**.
An operator who should see tenant content holds an explicit, audited
grant like anyone else. This is the deny-by-default consequence of A1
§8.1.3 ("not a bypass of workspace authorization"). `AdminGuard`'s
`role === 'admin'` check on admin endpoints stays the platform role's
only meaning. **Two shipped code paths violate this rule today and are
implementation defects this contract makes non-conformant:** (a) the
command authorization service short-circuits every command scope to
allowed for platform admins
(`apps/gateway/src/commands/command-authorization.service.ts`,
`hasScope` returning true when `role === 'admin'`), and (b) the MCP
scope derivation maps platform `admin` to tenant-admin MCP scopes
including task create/update
(`apps/gateway/src/mcp/mcp.service.ts`,
`deriveMcpToolScopesForUser`). Ratifying this contract revokes both;
§7.4 names them as the surfaces the deny-by-default test retires.
2. **Hierarchy grants** (`hierarchy_grants`, contract 1 §3) declare tenancy
authority at company, estate, or platform-project scope and evaluate down
the chain to workspace-scoped authorization (§3 below).
3. **Workspace membership** (SOT REQ-ID-001) remains its own mechanism.
A chain grant confers command authorization over descendant workspaces;
it does not create membership rows, and row-level principal positions
(task owner, proposer, decision actor) still require ACTIVE workspace
membership exactly as REQ-TEN-001/REQ-ID-001 acceptance states.
Consequence, stated so implementing PRs do not weaken REQ-TEN-001 to
remove the friction: a chain-granted actor who is not a workspace member
may issue the write commands their role implies but cannot occupy a
principal position — any command taking a principal argument must name
an ACTIVE member of the target workspace (§7.2 enumerates this cell).
4. **Team grant subjects are suspended.** Contract 1 §3.1 reserves a
`team_id` attachment point, but no ratified contract yet defines the
team it would bind: the only existing `teams` table is the legacy global
Brain table (own authority columns, no workspace binding, not
repurposed per contract 1 §1.3), while the SOT's teams are
workspace-bound (REQ-ID-001) — and a workspace-bound team holding a
company-level grant would be a cross-workspace authority group nothing
has ratified. Until a team contract defines the subject (which table,
which membership rows, and its relation to D2/REQ-ID-001), creating a
grant with a team subject MUST be refused at the command surface (the
schema column remains, per contract 1). §3's evaluation semantics for
team-conferred grants are specified now so the team contract activates
them without amending this one.
## 2. Role vocabulary
One vocabulary at every hierarchy level, totally ordered — a higher role
includes everything below it:
1. `viewer` — read: sees the node, its subtree structure, and the roll-up
aggregates over descendant workspaces (within contract 8's carve-out
bounds); read access to descendant workspace content per the SOT's read
command families. No mutation of anything.
2. `member` — work: everything `viewer` has, plus write authorization for
business/orchestration command families in descendant workspaces (the
concrete command-family mapping is implementation work under SOT
REQ-ID-001; this contract pins that `member` maps to the workspace write
families and nothing structural).
3. `owner` — structure: everything `member` has, plus hierarchy mutations on
the subtree (create/rename/delete child nodes, transfers per §5), and
grant management on the node and its subtree (§4).
No other value is valid in `hierarchy_grants.role`; the column is
constraint-checked against exactly these three. Extending the vocabulary is a
contract amendment, not an implementation decision.
## 3. Evaluation semantics
1. **Deny by default.** No grant on any ancestor → no authority. There are
no implicit grants: not from platform role (§1.1), not from creating a
node (§4.3), not from workspace membership (membership without a chain
grant confers exactly what the SOT's own membership rules confer inside
that workspace, nothing up the chain).
2. **Down-the-chain only.** A grant on a node applies to that node and its
entire descendant subtree. Nothing evaluates upward or sideways: a grant
on an estate says nothing about the parent company or sibling estates.
3. **Effective role = maximum.** A subject's effective role at any node is
the highest role among grants held directly by the subject's user on
that node or any ancestor — and, once the team contract activates team
subjects (§1.4), grants held by any team the user is a member of on that
node or any ancestor. Roles never subtract — there is no negative/deny
grant in this model; revocation is deletion (§6).
4. **Team grants follow live membership** (specified now, active only per
§1.4). A team grant confers its role on the team's current members,
evaluated at decision time. Leaving the team is loss of the grant with
§6's propagation bound.
5. **Live evaluation, fail closed.** Authorization decisions derive from the
live grant and team-membership rows (or from a cache that is invalidated
in the same transaction as any grant/membership/hierarchy mutation). A
decision path that cannot read grant state denies. No materialized ACL is
ever authoritative.
6. **Tenant context stays derived from authenticated authority**
(REQ-TEN-001). The chain adds where grants can be declared; a workspace
request is still authorized against that workspace, with the chain
contributing the effective role — never letting the chain become what A1
§8.1.3 forbids: "a bypass of workspace authorization".
## 4. Grant management
1. Creating, changing, or revoking a grant on a node requires effective
`owner` on that node (directly or via any ancestor).
2. **No self-escalation.** A grant manager cannot create a grant with a role
higher than their own effective role on the target node. Under the §2
vocabulary this rule is currently implied by §4.1 (managers are `owner`,
the top role — no constructible grant exceeds it); it is stated
explicitly so it survives any future amendment that decouples
grant-management authority from role height. Its observable is the §7.7
audit invariant, not a refusal test.
3. **Bootstrap of authority is explicit; inheritance covers the rest.**
Creating the first company (the wizard path, contract 3) and any
top-level company creation MUST name the initial `owner` grant in the
same audited operation — a top-level node has no ancestor to inherit
from, so without this the node would be unownable. Creating a child node
(estate, platform-project, workspace) requires effective `owner` on the
parent (§2.3) and confers no automatic grant; the creator's authority
over the new node already follows from §3.2 down-the-chain evaluation.
The creating command MAY additionally name an explicit initial grant for
a child node; it is not required to.
4. Every grant mutation is a semantic audit event under contract 1 §5.2's
guarantees, extended by this contract with two further fields: the event
carries actor, verb, target, **subject, and role** (subject and role are
this contract's addition; contract 1 §5.2 does not enumerate them).
5. **Role strings are namespaced.** `viewer`/`member` exist at hierarchy
level, `member`/`admin` on `users.role`, and the current command layer
uses a third `viewer|member|admin` vocabulary — same strings, different
meanings. Any serialized role string (audit events per §4.4, API
responses, logs) MUST identify its layer (e.g. `hierarchy:owner`,
`platform:admin`); a bare role string in a serialized artifact is
non-conformant.
## 5. Transfer authority (completes contract 1 §4.2)
"Authority over BOTH the source and the destination parent" means: effective
`owner` on the current parent node (or an ancestor) AND effective `owner` on
the destination parent node (or an ancestor), evaluated at transfer time in
the transfer's own transaction. One subject must hold both; two cooperating
half-authorized subjects are not a transfer protocol this contract defines.
## 6. Revocation propagation
1. Revoking a grant (deleting the row), removing a user from a team that
carries a grant (once team subjects activate, §1.4), or the cascade
deletion of a node's grants during node deletion (contract 1 §3.3) all
propagate identically: the authority derived from that grant is gone for
every descendant workspace.
2. **Bound:** the next authorization decision on any affected transport
decides against the revoked grant. Concretely: no new HTTP/MCP command
authorized by the revoked grant after the revoking transaction commits;
an open Socket.IO connection whose subscriptions depend on the revoked
grant is re-evaluated within 30 seconds or at its next inbound message,
whichever comes first (same bound as the identity contract's §7.1
deactivation rule; same mechanism may serve both).
3. Revocation is subtractive only in effect, not in representation: the
evaluator never needs tombstones; deletion of the row is the revocation.
## 7. Verification requirements
Binding on the implementing PRs (extends A1 §8.3 acceptance 23 and
contract 1 §6):
1. Vocabulary: the role CHECK constraint rejects any value outside
`viewer|member|owner` (real-PostgreSQL witness, `ci-postgres` service in
the `test` CI step).
2. Per-level conferral: for each of the three levels × three roles, a grant
yields exactly the implied workspace authorization in a descendant
workspace and nothing in a non-descendant workspace (the A1 §8.3
"exactly the permissions the chain implies" matrix, enumerated). The
matrix includes: a chain grant creates zero workspace-membership rows
(assert row counts); a chain-granted non-member is refused as the
principal argument of any principal-taking command while their
non-principal writes succeed (§1.3); structure reads leak no existence
of nodes the reader holds no grant on (no cross-tenant existence
oracle, A1 §8.3 acceptance 3).
3. Ordering: `owner``member``viewer` behaviorally — each higher role
passes every lower role's positive cases.
4. Deny-by-default: platform `admin` with no grant reaches no tenant
content — asserted against the two §1.1 non-conformant surfaces after
their retirement: the command-authorization admin short-circuit and the
MCP tenant-admin scope derivation both gone (a platform admin with no
grant is refused workspace commands and receives no tenant MCP scopes);
workspace member with no chain grant gains nothing outside SOT
membership semantics; fresh account reaches nothing (identity contract
§1.4 cross-check).
5. Team subjects: while suspended (§1.4), creating a team-subject grant is
refused at the command surface. On activation by the team contract:
user-direct and team-conferred grants combine to the maximum; team-leave
drops authority within the §6.2 bound; decision-time evaluation
witnessed (grant added → next decision allows; no restart or re-login
required).
6. Revocation: each revocation path in §6.1 denies the next command on
every transport; the socket bound is measured; a cached-authorization
implementation proves transactional invalidation (grant revoked and
decision made on two distinct physical connections). Fail-closed fault
witness for §3.5: with grant state unreadable (fault injection), the
decision denies.
7. Grant management: non-`owner` cannot mutate grants; top-level company
creation without the named initial `owner` grant is refused, while child
node creation under ancestor authority succeeds without one (§4.3 both
directions); every mutation produces its audit event with the §4.4
fields. Self-escalation observable: over the audit event stream, every
grant-create/change event's role is ≤ the acting user's effective role
on the target at event time (reconstructable invariant, not a refusal
test — see §4.2).
8. Transfer: both-sides `owner` accepted, each single-side case refused
(completing contract 1 §6.5).
## Ruling request
Ratify sections 17 as written, with one decision embedded and one
interpretive resolution named:
- Decision: platform `admin` confers no implicit tenant access — operators
see tenant content only through explicit, audited grants (§1.1), which
retires the two existing admin bypass paths named there. Say "agreed" or
name the implicit access you want platform admins to keep.
- Interpretive resolution (for visibility, not a separate question): PRD
Part I §4 says "admins restrict access per company, estate, and project";
this contract resolves "admins" as hierarchy `owner`s (§4.1), not
platform admins. A1 §8.1.3 does not attribute grant declaration to
platform admins, and the §1.1 decision above is what makes this reading
binding.