import { randomUUID } from 'node:crypto'; import { Inject, Injectable } from '@nestjs/common'; import { and, asc, companies, eq, estates, hierarchyGrants, inArray, platformProjects, users, workspaces, type Db, } from '@mosaicstack/db'; import { DB } from '../database/database.module.js'; import { appendHierarchyEvent, buildNodeSnapshot, HierarchyAuditIdempotencyConflictError, } from './hierarchy-audit.repository.js'; import { evaluateEffectiveRole, grantedCompanyIds, namespacedHierarchyRole, roleAtLeast, type GrantTargetKind, type HierarchyGrantRole, } from './hierarchy-grant-evaluation.js'; /** * Hierarchy command repository (contract 1 §5, contract 2 §4). * * The ONLY writer of the hierarchy class tables (companies, estates, * platform_projects, hierarchy_grants) — it is the writer-coverage * allowlist's sole entry. Every command runs one transaction that * authorizes (live grant evaluation inside the same transaction), mutates, * and appends the semantic audit event + outbox record via the M4-1b-i * machinery, so state, event, and outbox commit or roll back together * (REQ-AUD-001). * * Authorization failure and target-not-found both return `not_found` * (contract 1 §6.7: no existence oracle — an unauthorized caller learns * nothing a stranger would not). `forbidden` appears only where the caller * already knows the surface exists independent of any node: the admin-only * visibility change (§5.5). Serialized role strings are namespaced (§4.5). */ export type HierarchyCommandFailure = | { readonly ok: false; readonly error: 'not_found' } | { readonly ok: false; readonly error: 'forbidden'; readonly message: string } | { readonly ok: false; readonly error: 'conflict'; readonly message: string }; export type HierarchyResult = ({ readonly ok: true } & T) | HierarchyCommandFailure; export interface CompanyView { readonly id: string; readonly name: string; readonly slug: string; readonly visibility: string; } export interface NodeView { readonly id: string; readonly name: string; readonly slug: string; } export interface GrantView { readonly id: string; readonly userId: string; readonly targetKind: GrantTargetKind; readonly targetId: string; /** Namespaced (§4.5), e.g. `hierarchy:owner`. */ readonly role: string; readonly grantedBy: string; } /** Directory rows are closed-field: existence, name, slug — nothing else (§2.8). */ export interface DirectoryEntry { readonly id: string; readonly name: string; readonly slug: string; } type Tx = Pick; type GrantRow = typeof hierarchyGrants.$inferSelect; const NOT_FOUND: HierarchyCommandFailure = { ok: false, error: 'not_found' }; function conflict(message: string): HierarchyCommandFailure { return { ok: false, error: 'conflict', message }; } function grantTarget(row: GrantRow): { kind: GrantTargetKind; id: string } { if (row.companyId) return { kind: 'company', id: row.companyId }; if (row.estateId) return { kind: 'estate', id: row.estateId }; return { kind: 'platform_project', id: row.platformProjectId as string }; } /** Grant event snapshot (contract 2 §4.4): subject, target, namespaced role, grantor. */ function grantSnapshot(row: GrantRow): Record { const target = grantTarget(row); return { id: row.id, subject: { userId: row.userId }, target: { kind: target.kind, id: target.id }, role: namespacedHierarchyRole(row.role as HierarchyGrantRole), grantedBy: row.grantedBy, }; } function grantView(row: GrantRow): GrantView { const target = grantTarget(row); return { id: row.id, userId: row.userId as string, targetKind: target.kind, targetId: target.id, role: namespacedHierarchyRole(row.role as HierarchyGrantRole), grantedBy: row.grantedBy, }; } function companyView(row: typeof companies.$inferSelect): CompanyView { return { id: row.id, name: row.name, slug: row.slug, visibility: row.visibility }; } interface CommandContext { readonly actorId: string; readonly idempotencyKey: string; readonly correlationId: string; } @Injectable() export class HierarchyRepository { constructor(@Inject(DB) private readonly db: Db) {} private async run( idempotencyKey: string | undefined, actorId: string, body: (tx: Tx, ctx: CommandContext) => Promise>, ): Promise> { const ctx: CommandContext = { actorId, idempotencyKey: idempotencyKey ?? randomUUID(), correlationId: randomUUID(), }; try { return await this.db.transaction(async (tx) => body(tx, ctx)); } catch (error) { // A key replayed with different content aborts the whole command — // the transaction (state change included) has rolled back (§6.4). if (error instanceof HierarchyAuditIdempotencyConflictError) { return conflict(error.message); } throw error; } } private async requireOwner( tx: Tx, actorId: string, kind: GrantTargetKind, id: string, ): Promise { return roleAtLeast(await evaluateEffectiveRole(tx, actorId, kind, id), 'owner'); } private async isPlatformAdmin(tx: Tx, actorId: string): Promise { const rows = await tx .select({ role: users.role }) .from(users) .where(eq(users.id, actorId)) .limit(1); return rows[0]?.role === 'admin'; } // ── companies ──────────────────────────────────────────────────────────── /** * Any authenticated user may create a company; the same audited operation * writes the creator's initial owner grant (§4.3), causation-linked to the * create event. Visibility is always 'private' — the command takes no * visibility input (§5.5). */ createCompany(input: { actorId: string; name: string; slug: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { const inserted = await tx .insert(companies) .values({ name: input.name, slug: input.slug }) .onConflictDoNothing() .returning(); const company = inserted[0]; if (!company) return conflict('company slug already exists'); const grantRows = await tx .insert(hierarchyGrants) .values({ userId: ctx.actorId, companyId: company.id, role: 'owner', grantedBy: ctx.actorId, }) .returning(); const grant = grantRows[0] as GrantRow; const snapshot = await buildNodeSnapshot(tx, 'company', company.id); const created = await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'create', targetKind: 'company', targetId: company.id, targetSnapshot: { ...snapshot }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'grant_create', targetKind: 'grant', targetId: grant.id, targetSnapshot: grantSnapshot(grant), correlationId: ctx.correlationId, causationId: created.event.id, idempotencyKey: `${ctx.idempotencyKey}:grant`, }); return { ok: true, company: companyView(company), grant: grantView(grant) }; }); } renameCompany(input: { actorId: string; companyId: string; name: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) { return NOT_FOUND; } const rows = await tx .select() .from(companies) .where(eq(companies.id, input.companyId)) .limit(1); const previous = rows[0]; if (!previous) return NOT_FOUND; const updated = await tx .update(companies) .set({ name: input.name, updatedAt: new Date() }) .where(eq(companies.id, input.companyId)) .returning(); const company = updated[0] as typeof companies.$inferSelect; const snapshot = await buildNodeSnapshot(tx, 'company', company.id); await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'rename', targetKind: 'company', targetId: company.id, targetSnapshot: { ...snapshot, previousName: previous.name }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); return { ok: true, company: companyView(company) }; }); } /** * Admin-only until the company-CRUD capability ratifies (§5.5) — the one * hierarchy mutation a platform admin performs without a grant. A * non-admin caller (owner included) gets `forbidden` before any company * read: the refusal reveals nothing about the target's existence. */ changeCompanyVisibility(input: { actorId: string; companyId: string; visibility: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { if (!(await this.isPlatformAdmin(tx, ctx.actorId))) { return { ok: false, error: 'forbidden', message: 'visibility change is platform-admin-only', }; } const rows = await tx .select() .from(companies) .where(eq(companies.id, input.companyId)) .limit(1); const previous = rows[0]; if (!previous) return NOT_FOUND; const updated = await tx .update(companies) .set({ visibility: input.visibility, updatedAt: new Date() }) .where(eq(companies.id, input.companyId)) .returning(); const company = updated[0] as typeof companies.$inferSelect; const snapshot = await buildNodeSnapshot(tx, 'company', company.id); await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'visibility_change', targetKind: 'company', targetId: company.id, // Old and new values are event content (§5.5). targetSnapshot: { ...snapshot, previousVisibility: previous.visibility, visibility: company.visibility, }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); return { ok: true, company: companyView(company) }; }); } deleteCompany(input: { actorId: string; companyId: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) { return NOT_FOUND; } const children = await tx .select({ id: estates.id }) .from(estates) .where(eq(estates.companyId, input.companyId)) .limit(1); if (children.length > 0) return conflict('company still has estates'); // Snapshot and grants are read before the delete; target FKs cascade // the grant rows, and each cascaded deletion is audited (§5.2). const snapshot = await buildNodeSnapshot(tx, 'company', input.companyId); const grants = await tx .select() .from(hierarchyGrants) .where(eq(hierarchyGrants.companyId, input.companyId)); await tx.delete(companies).where(eq(companies.id, input.companyId)); const deleted = await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'delete', targetKind: 'company', targetId: input.companyId, targetSnapshot: { ...snapshot }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); for (const grant of grants) { await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'grant_revoke', targetKind: 'grant', targetId: grant.id, targetSnapshot: grantSnapshot(grant), correlationId: ctx.correlationId, causationId: deleted.event.id, idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`, }); } return { ok: true, deletedId: input.companyId }; }); } // ── estates ────────────────────────────────────────────────────────────── /** Child creation requires owner on the parent and confers no grant (§4.3). */ createEstate(input: { actorId: string; companyId: string; name: string; slug: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.companyId))) { return NOT_FOUND; } const inserted = await tx .insert(estates) .values({ companyId: input.companyId, name: input.name, slug: input.slug }) .onConflictDoNothing() .returning(); const estate = inserted[0]; if (!estate) return conflict('estate slug already exists in company'); const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id); await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'create', targetKind: 'estate', targetId: estate.id, targetSnapshot: { ...snapshot }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } }; }); } renameEstate(input: { actorId: string; estateId: string; name: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) { return NOT_FOUND; } const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1); const previous = rows[0]; if (!previous) return NOT_FOUND; const updated = await tx .update(estates) .set({ name: input.name }) .where(eq(estates.id, input.estateId)) .returning(); const estate = updated[0] as typeof estates.$inferSelect; const snapshot = await buildNodeSnapshot(tx, 'estate', estate.id); await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'rename', targetKind: 'estate', targetId: estate.id, targetSnapshot: { ...snapshot, previousName: previous.name }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } }; }); } /** Transfer requires effective owner on BOTH parents, evaluated in the transfer's own transaction (§5). */ transferEstate(input: { actorId: string; estateId: string; destinationCompanyId: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { const rows = await tx.select().from(estates).where(eq(estates.id, input.estateId)).limit(1); const estate = rows[0]; if (!estate) return NOT_FOUND; if (!(await this.requireOwner(tx, ctx.actorId, 'company', estate.companyId))) { return NOT_FOUND; } if (!(await this.requireOwner(tx, ctx.actorId, 'company', input.destinationCompanyId))) { return NOT_FOUND; } if (estate.companyId === input.destinationCompanyId) { return conflict('estate already belongs to the destination company'); } const collision = await tx .select({ id: estates.id }) .from(estates) .where( and(eq(estates.companyId, input.destinationCompanyId), eq(estates.slug, estate.slug)), ) .limit(1); if (collision.length > 0) return conflict('destination company already has that estate slug'); const parents = await tx .select({ id: companies.id, slug: companies.slug }) .from(companies) .where(inArray(companies.id, [estate.companyId, input.destinationCompanyId])); const source = parents.find((p) => p.id === estate.companyId); const destination = parents.find((p) => p.id === input.destinationCompanyId); if (!source || !destination) return NOT_FOUND; await tx .update(estates) .set({ companyId: input.destinationCompanyId }) .where(eq(estates.id, input.estateId)); const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId); await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'transfer', targetKind: 'estate', targetId: input.estateId, targetSnapshot: { ...snapshot }, transferFrom: { kind: 'company', id: source.id, slug: source.slug }, transferTo: { kind: 'company', id: destination.id, slug: destination.slug }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); return { ok: true, estate: { id: estate.id, name: estate.name, slug: estate.slug } }; }); } deleteEstate(input: { actorId: string; estateId: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) { return NOT_FOUND; } const children = await tx .select({ id: platformProjects.id }) .from(platformProjects) .where(eq(platformProjects.estateId, input.estateId)) .limit(1); if (children.length > 0) return conflict('estate still has platform projects'); const snapshot = await buildNodeSnapshot(tx, 'estate', input.estateId); const grants = await tx .select() .from(hierarchyGrants) .where(eq(hierarchyGrants.estateId, input.estateId)); await tx.delete(estates).where(eq(estates.id, input.estateId)); const deleted = await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'delete', targetKind: 'estate', targetId: input.estateId, targetSnapshot: { ...snapshot }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); for (const grant of grants) { await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'grant_revoke', targetKind: 'grant', targetId: grant.id, targetSnapshot: grantSnapshot(grant), correlationId: ctx.correlationId, causationId: deleted.event.id, idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`, }); } return { ok: true, deletedId: input.estateId }; }); } // ── platform projects ──────────────────────────────────────────────────── createPlatformProject(input: { actorId: string; estateId: string; name: string; slug: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.estateId))) { return NOT_FOUND; } const inserted = await tx .insert(platformProjects) .values({ estateId: input.estateId, name: input.name, slug: input.slug }) .onConflictDoNothing() .returning(); const project = inserted[0]; if (!project) return conflict('platform project slug already exists in estate'); const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id); await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'create', targetKind: 'platform_project', targetId: project.id, targetSnapshot: { ...snapshot }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); return { ok: true, platformProject: { id: project.id, name: project.name, slug: project.slug }, }; }); } renamePlatformProject(input: { actorId: string; platformProjectId: string; name: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { if ( !(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId)) ) { return NOT_FOUND; } const rows = await tx .select() .from(platformProjects) .where(eq(platformProjects.id, input.platformProjectId)) .limit(1); const previous = rows[0]; if (!previous) return NOT_FOUND; const updated = await tx .update(platformProjects) .set({ name: input.name }) .where(eq(platformProjects.id, input.platformProjectId)) .returning(); const project = updated[0] as typeof platformProjects.$inferSelect; const snapshot = await buildNodeSnapshot(tx, 'platform_project', project.id); await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'rename', targetKind: 'platform_project', targetId: project.id, targetSnapshot: { ...snapshot, previousName: previous.name }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); return { ok: true, platformProject: { id: project.id, name: project.name, slug: project.slug }, }; }); } transferPlatformProject(input: { actorId: string; platformProjectId: string; destinationEstateId: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { const rows = await tx .select() .from(platformProjects) .where(eq(platformProjects.id, input.platformProjectId)) .limit(1); const project = rows[0]; if (!project) return NOT_FOUND; if (!(await this.requireOwner(tx, ctx.actorId, 'estate', project.estateId))) { return NOT_FOUND; } if (!(await this.requireOwner(tx, ctx.actorId, 'estate', input.destinationEstateId))) { return NOT_FOUND; } if (project.estateId === input.destinationEstateId) { return conflict('platform project already belongs to the destination estate'); } const collision = await tx .select({ id: platformProjects.id }) .from(platformProjects) .where( and( eq(platformProjects.estateId, input.destinationEstateId), eq(platformProjects.slug, project.slug), ), ) .limit(1); if (collision.length > 0) { return conflict('destination estate already has that platform project slug'); } const parents = await tx .select({ id: estates.id, slug: estates.slug }) .from(estates) .where(inArray(estates.id, [project.estateId, input.destinationEstateId])); const source = parents.find((p) => p.id === project.estateId); const destination = parents.find((p) => p.id === input.destinationEstateId); if (!source || !destination) return NOT_FOUND; await tx .update(platformProjects) .set({ estateId: input.destinationEstateId }) .where(eq(platformProjects.id, input.platformProjectId)); const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId); await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'transfer', targetKind: 'platform_project', targetId: input.platformProjectId, targetSnapshot: { ...snapshot }, transferFrom: { kind: 'estate', id: source.id, slug: source.slug }, transferTo: { kind: 'estate', id: destination.id, slug: destination.slug }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); return { ok: true, platformProject: { id: project.id, name: project.name, slug: project.slug }, }; }); } deletePlatformProject(input: { actorId: string; platformProjectId: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { if ( !(await this.requireOwner(tx, ctx.actorId, 'platform_project', input.platformProjectId)) ) { return NOT_FOUND; } const children = await tx .select({ id: workspaces.id }) .from(workspaces) .where(eq(workspaces.platformProjectId, input.platformProjectId)) .limit(1); if (children.length > 0) return conflict('platform project still has workspaces'); const snapshot = await buildNodeSnapshot(tx, 'platform_project', input.platformProjectId); const grants = await tx .select() .from(hierarchyGrants) .where(eq(hierarchyGrants.platformProjectId, input.platformProjectId)); await tx.delete(platformProjects).where(eq(platformProjects.id, input.platformProjectId)); const deleted = await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'delete', targetKind: 'platform_project', targetId: input.platformProjectId, targetSnapshot: { ...snapshot }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); for (const grant of grants) { await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'grant_revoke', targetKind: 'grant', targetId: grant.id, targetSnapshot: grantSnapshot(grant), correlationId: ctx.correlationId, causationId: deleted.event.id, idempotencyKey: `${ctx.idempotencyKey}:revoke:${grant.id}`, }); } return { ok: true, deletedId: input.platformProjectId }; }); } // ── grants ─────────────────────────────────────────────────────────────── /** Grant management requires effective owner on the target (§4.1). */ createGrant(input: { actorId: string; userId: string; targetKind: GrantTargetKind; targetId: string; role: HierarchyGrantRole; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { if (!(await this.requireOwner(tx, ctx.actorId, input.targetKind, input.targetId))) { return NOT_FOUND; } const subject = await tx .select({ id: users.id }) .from(users) .where(eq(users.id, input.userId)) .limit(1); if (subject.length === 0) return conflict('subject user does not exist'); const inserted = await tx .insert(hierarchyGrants) .values({ userId: input.userId, companyId: input.targetKind === 'company' ? input.targetId : null, estateId: input.targetKind === 'estate' ? input.targetId : null, platformProjectId: input.targetKind === 'platform_project' ? input.targetId : null, role: input.role, grantedBy: ctx.actorId, }) .onConflictDoNothing() .returning(); const grant = inserted[0]; if (!grant) return conflict('grant already exists'); await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'grant_create', targetKind: 'grant', targetId: grant.id, targetSnapshot: grantSnapshot(grant), correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); return { ok: true, grant: grantView(grant) }; }); } changeGrant(input: { actorId: string; grantId: string; role: HierarchyGrantRole; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { const rows = await tx .select() .from(hierarchyGrants) .where(eq(hierarchyGrants.id, input.grantId)) .limit(1); const existing = rows[0]; if (!existing) return NOT_FOUND; const target = grantTarget(existing); if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) { return NOT_FOUND; } // Team subjects are suspended (§1.4); the command surface never // creates them, so this only fires on out-of-band rows. if (!existing.userId) return conflict('team grant subjects are suspended'); if (existing.role === input.role) return conflict('grant already holds that role'); const duplicate = await tx .select({ id: hierarchyGrants.id }) .from(hierarchyGrants) .where( and( eq(hierarchyGrants.userId, existing.userId), target.kind === 'company' ? eq(hierarchyGrants.companyId, target.id) : target.kind === 'estate' ? eq(hierarchyGrants.estateId, target.id) : eq(hierarchyGrants.platformProjectId, target.id), eq(hierarchyGrants.role, input.role), ), ) .limit(1); if (duplicate.length > 0) { return conflict('subject already holds that role on the target'); } const updated = await tx .update(hierarchyGrants) .set({ role: input.role }) .where(eq(hierarchyGrants.id, input.grantId)) .returning(); const grant = updated[0] as GrantRow; await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'grant_change', targetKind: 'grant', targetId: grant.id, targetSnapshot: { ...grantSnapshot(grant), previousRole: namespacedHierarchyRole(existing.role as HierarchyGrantRole), }, correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); return { ok: true, grant: grantView(grant) }; }); } /** Revocation is row deletion (§6): the next evaluation denies, nothing lingers. */ revokeGrant(input: { actorId: string; grantId: string; idempotencyKey?: string; }): Promise> { return this.run(input.idempotencyKey, input.actorId, async (tx, ctx) => { const rows = await tx .select() .from(hierarchyGrants) .where(eq(hierarchyGrants.id, input.grantId)) .limit(1); const existing = rows[0]; if (!existing) return NOT_FOUND; const target = grantTarget(existing); if (!(await this.requireOwner(tx, ctx.actorId, target.kind, target.id))) { return NOT_FOUND; } await tx.delete(hierarchyGrants).where(eq(hierarchyGrants.id, input.grantId)); await appendHierarchyEvent(tx, { actorId: ctx.actorId, verb: 'grant_revoke', targetKind: 'grant', targetId: existing.id, targetSnapshot: grantSnapshot(existing), correlationId: ctx.correlationId, idempotencyKey: ctx.idempotencyKey, }); return { ok: true, revokedId: existing.id }; }); } // ── reads ──────────────────────────────────────────────────────────────── /** * The directory: every directory-class company, closed-field (§2.8). The * sole ratified existence-disclosure carve-out (§6.7 / A2 §9.1.2). */ async listDirectory(): Promise { return this.db .select({ id: companies.id, name: companies.name, slug: companies.slug }) .from(companies) .where(eq(companies.visibility, 'directory')) .orderBy(asc(companies.name)); } /** Companies the user holds any grant on (company or descendant, §2.8). */ async listGrantedCompanies(userId: string): Promise { const ids = await grantedCompanyIds(this.db, userId); if (ids.length === 0) return []; const rows = await this.db .select() .from(companies) .where(inArray(companies.id, ids)) .orderBy(asc(companies.name)); return rows.map(companyView); } }