Skip to main content
All Docs
FeaturesagentOS Block ManagerUpdated April 12, 2026

Portfolio Reporting — Single-Owner Scope

Portfolio Reporting — Single-Owner Scope

Compliance rule: Portfolio-level reporting always aggregates data within a single owner's blocks only. Financial data across different owners' management accounts must never be merged or combined. Different owners' funds are legally distinct client money.

Overview

The portfolio reporting system enforces a strict ownership boundary on all financial reports. When an agent manages blocks on behalf of multiple owners, each owner's financial data is isolated — there is no combined P&L or cross-owner ledger view.

Agents can see a structural overview of their entire portfolio from the agent dashboard, but detailed financial data is always accessed owner-by-owner.


Endpoints

All endpoints are available under the portfolioReporting tRPC router and require the caller to be authenticated and org-scoped.

portfolioReporting.ownerPortfolio

Returns a full portfolio summary for a single owner.

Permission required: financials.read

Input:

{ ownerId: string }

Returns:

{
  owner: {
    id: string;
    name: string;
    ownerType: string;
    email: string | null;
    phone: string | null;
  };
  blocks: OwnerScopedBlock[];  // All blocks belonging to this owner
  totals: {
    blockCount: number;
    totalUnits: number;
    blocksWithManagementAccounts: number;
    blocksWithClientMoneyAccounts: number;
  };
}

This endpoint logs a portfolio.owner_summary_viewed audit event on each call.


portfolioReporting.validateReportScope

Validates that a set of block IDs all belong to a single owner. Use this before generating any report that takes multiple block IDs as input.

Permission required: financials.read

Input:

{
  blockIds: string[];    // 1–500 block IDs
  reportType?: string;  // Optional label for audit logging
}

Returns:

{
  valid: boolean;
  ownerId: string | null;
  ownerName: string | null;
  blockCount: number;
  distinctOwnerIds: string[];    // Populated when valid=false and blocks span multiple owners
  rejectionReason: string | null;
}

When valid is false and the blocks span multiple owners, the attempt is written to the audit trail as portfolio.cross_owner_report_rejected.


portfolioReporting.agentOverview

Agent-level dashboard showing per-owner summaries across the entire portfolio.

Permission required: financials.read

Input: None

Returns:

{
  agentTotals: {
    ownerCount: number;
    totalBlocks: number;
    totalUnits: number;
    totalActiveClientMoneyAccounts: number;
    totalActiveManagementSources: number;
    unassignedBlocks: number;
  };
  owners: Array<{
    owner: { id, name, ownerType, email, createdAt };
    blockCount: number;
    totalUnits: number;
    activeClientMoneyAccounts: number;
    activeManagementAccountSources: number;
    // No financial totals — intentionally omitted
  }>;
  _enforcement: {
    rule: string;
    message: string;
  };
}

Important: The agentTotals object contains only structural counts (blocks, units). Financial totals such as balances and P&L figures are never aggregated at this level. To view financial data, call ownerPortfolio for a specific owner.


portfolioReporting.ownerBlocksSummary

Lightweight block list for a single owner. Designed for populating block selectors in report-building UIs.

Permission required: blocks.read

Input:

{ ownerId: string }

Returns:

{
  ownerId: string;
  blocks: Array<{
    id: string;
    name: string;
    addressLine1: string | null;
    city: string | null;
    postcode: string | null;
    totalUnits: number | null;
  }>;
  blockCount: number;
}

Utility Functions

The enforcement logic lives in src/lib/portfolio-scope.ts and should be used directly by any future financial reporting procedure.

requireSingleOwnerScope

The primary guard for financial report procedures. Throws TRPCError(BAD_REQUEST) if the provided blocks span more than one owner.

import { requireSingleOwnerScope } from "@/lib/portfolio-scope";

// At the top of any financial reporting procedure:
const { ownerId } = await requireSingleOwnerScope(
  blockIds,
  ctx.orgId,
  { logAttempt: true, userId: ctx.userId, reportType: "P&L" }
);
// If blockIds span multiple owners, execution stops here with TRPCError(BAD_REQUEST)
OptionTypeDescription
logAttemptbooleanWrite rejected cross-owner attempts to the audit trail
userIdstringUser ID for the audit log entry
reportTypestringHuman-readable label for the report type being attempted

validateSingleOwnerScope

Same validation logic as requireSingleOwnerScope, but returns a result object instead of throwing. Use when you need to show the user a detailed error.

import { validateSingleOwnerScope } from "@/lib/portfolio-scope";

const result = await validateSingleOwnerScope(blockIds, ctx.orgId);
if (!result.valid) {
  // result.rejectionReason explains why
  // result.distinctOwnerIds lists the owner IDs found
}

getOwnerScopedBlocks

Fetch all blocks for a single owner. All returned blocks are guaranteed to belong to exactly one owner.

import { getOwnerScopedBlocks } from "@/lib/portfolio-scope";

const blocks = await getOwnerScopedBlocks(ownerId, ctx.orgId);

buildOwnerPortfolioSummary

Builds a complete OwnerPortfolioSummary for a single owner. Includes owner details, block list, unit totals, and account status counts.

import { buildOwnerPortfolioSummary } from "@/lib/portfolio-scope";

const summary = await buildOwnerPortfolioSummary(ownerId, ctx.orgId);

Audit Trail

The following audit actions are emitted by this feature:

ActionTrigger
portfolio.owner_summary_viewedownerPortfolio endpoint called successfully
portfolio.cross_owner_report_rejectedvalidateReportScope or requireSingleOwnerScope detects blocks spanning multiple owners

Building Future Financial Features

When implementing P&L, ledger, or service charge reporting, always begin the procedure with requireSingleOwnerScope:

import { requireSingleOwnerScope } from "@/lib/portfolio-scope";

export const myFinancialReportProcedure = orgProcedure
  .input(z.object({ blockIds: z.array(z.string()) }))
  .query(async ({ ctx, input }) => {
    requirePermission(ctx.platformRole as PlatformRole, "financials.read");

    // Enforce single-owner scope before any financial aggregation
    const { ownerId } = await requireSingleOwnerScope(
      input.blockIds,
      ctx.orgId,
      { logAttempt: true, userId: ctx.userId, reportType: "MyReport" }
    );

    // All further queries are now safely scoped to a single owner
    // ...
  });

Financial totals (balances, P&L) must never be aggregated across owner boundaries, even at the agent dashboard level. Structural counts (number of blocks, number of units) are safe to aggregate.