Skip to content
This repository was archived by the owner on Jun 22, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions apphosting.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,17 @@ env:
availability:
- RUNTIME

# Shared ingest secret for the cross-tenant Foundry portfolio rollup (Atlas
# Situational Awareness, Phase 51 PRT). Atlas tenants POST their PII-free
# rollup to /api/platform/ingest/portfolio-rollup with this as x-ingest-secret.
# ONE shared value across every Atlas tenant project AND here. Provision FIRST
# (a missing secret fails the App Hosting rollout). Set with:
# gcloud secrets create PORTFOLIO_ROLLUP_INGEST_SECRET --project triarch-dev-website --data-file=- < value
- variable: PORTFOLIO_ROLLUP_INGEST_SECRET
secret: PORTFOLIO_ROLLUP_INGEST_SECRET
availability:
- RUNTIME

# CL-6 server-side adoption enforcement mode (Phase 27).
# Values: 'off' | 'warn' | 'enforce'
# off - gate check skipped entirely (rollback escape hatch)
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "triarch-dev",
"version": "2.27.0",
"version": "2.28.0",
"private": true,
"scripts": {
"dev": "next dev",
Expand Down
177 changes: 177 additions & 0 deletions src/app/api/platform/ingest/portfolio-rollup/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import { NextRequest, NextResponse } from 'next/server';
import { timingSafeEqual } from 'node:crypto';
import { eq } from 'drizzle-orm';
import { db } from '@/lib/db';
import { tenantPortfolioRollup } from '@/db/schema';

export const dynamic = 'force-dynamic';
export const runtime = 'nodejs';

// POST /api/platform/ingest/portfolio-rollup
//
// Central RECEIVER for the cross-tenant Foundry Situational-Awareness portfolio
// rollup. Each Atlas tenant pushes its OWN PII-free rollup here daily; this route
// validates, then REPLACES the rows for that tenant. The per-tenant push cron lives
// in the atlas repo (/api/internal/portfolio-rollup-report, Phase 51 PRT).
//
// AUTH β€” mirrors /ingest/llm-usage: the frozen wire contract mandates a single shared
// header `x-ingest-secret`, validated against PORTFOLIO_ROLLUP_INGEST_SECRET with a
// CONSTANT-TIME comparison; FAILS CLOSED (503 if unconfigured, 401 on mismatch). The
// secret and full payloads are NEVER logged.
//
// PII: the payload is PII-free by construction (per-category counts + max severity +
// an at-risk-$ SUM). This route stores ONLY those aggregates β€” never an entity or value.

const CATEGORIES = ['health', 'ingest', 'intelGaps', 'focus'] as const;
type Category = (typeof CATEGORIES)[number];
const SEVERITIES = ['critical', 'warn', 'info', 'unknown'] as const;

interface NormalizedCategory {
category: Category;
count: number;
maxSeverity: string | null;
severityCounts: { critical: number; warn: number; info: number; unknown: number };
totalAtRiskMicros: number | null;
}

interface NormalizedBody {
tenantSlug: string;
generatedAt: Date;
categories: NormalizedCategory[];
}

function asInt(v: unknown): number {
const n = typeof v === 'number' ? v : Number(v);
if (!Number.isFinite(n)) return 0;
return Math.trunc(n);
}

function normalizeSeverityCounts(raw: unknown): NormalizedCategory['severityCounts'] {
const r = (raw && typeof raw === 'object' ? raw : {}) as Record<string, unknown>;
return { critical: asInt(r.critical), warn: asInt(r.warn), info: asInt(r.info), unknown: asInt(r.unknown) };
}

/**
* Validate + normalize the wire body. Returns { ok: false, error } on any structural
* problem (caller -> 400), or { ok: true, body }. Unknown categories are skipped.
*/
export function normalizeRollupBody(
raw: unknown,
): { ok: true; body: NormalizedBody } | { ok: false; error: string } {
if (!raw || typeof raw !== 'object') return { ok: false, error: 'body must be a JSON object' };
const b = raw as Record<string, unknown>;

const tenantSlug = (typeof b.tenantSlug === 'string' ? b.tenantSlug : '').trim();
if (!tenantSlug) return { ok: false, error: 'tenantSlug is required' };

if (typeof b.generatedAt !== 'string') return { ok: false, error: 'generatedAt is required (ISO string)' };
const generatedAt = new Date(b.generatedAt);
if (Number.isNaN(generatedAt.getTime())) return { ok: false, error: 'generatedAt is not a valid date' };

const rollup = (b.rollup && typeof b.rollup === 'object' ? b.rollup : null) as Record<string, unknown> | null;
if (!rollup || !Array.isArray(rollup.categories)) {
return { ok: false, error: 'rollup.categories must be an array' };
}

const categories: NormalizedCategory[] = [];
for (const c of rollup.categories) {
if (!c || typeof c !== 'object') continue;
const cat = c as Record<string, unknown>;
const category = cat.category;
if (typeof category !== 'string' || !(CATEGORIES as readonly string[]).includes(category)) continue; // skip unknown
const maxSeverityRaw = cat.maxSeverity;
const maxSeverity =
typeof maxSeverityRaw === 'string' && (SEVERITIES as readonly string[]).includes(maxSeverityRaw)
? maxSeverityRaw
: null;
const usd = cat.totalAtRiskUsd;
const totalAtRiskMicros = usd == null || !Number.isFinite(Number(usd)) ? null : Math.round(Number(usd) * 1e6);
categories.push({
category: category as Category,
count: asInt(cat.count),
maxSeverity,
severityCounts: normalizeSeverityCounts(cat.severityCounts),
totalAtRiskMicros,
});
}

return { ok: true, body: { tenantSlug, generatedAt, categories } };
}

function secretMatches(provided: string | null, expected: string): boolean {
if (!provided) return false;
const a = Buffer.from(provided);
const b = Buffer.from(expected);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}

export async function POST(req: NextRequest) {
const expected = process.env.PORTFOLIO_ROLLUP_INGEST_SECRET;
if (!expected) {
return NextResponse.json({ error: 'ingest not configured' }, { status: 503 });
}

const provided = req.headers.get('x-ingest-secret');
if (!secretMatches(provided, expected)) {
return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
}

let raw: unknown;
try {
raw = await req.json();
} catch {
return NextResponse.json({ error: 'invalid JSON body' }, { status: 400 });
}

const parsed = normalizeRollupBody(raw);
if (!parsed.ok) {
return NextResponse.json({ error: parsed.error }, { status: 400 });
}
const { tenantSlug, generatedAt, categories } = parsed.body;

const now = new Date();
let upserted = 0;

// Replace the tenant's rows atomically: a re-push may carry fewer categories, so
// DELETE the tenant's rows then INSERT the fresh set inside one transaction.
try {
await db.transaction(async (tx) => {
await tx.delete(tenantPortfolioRollup).where(eq(tenantPortfolioRollup.tenantSlug, tenantSlug));
for (const c of categories) {
await tx
.insert(tenantPortfolioRollup)
.values({
tenantSlug,
category: c.category,
count: c.count,
maxSeverity: c.maxSeverity,
severityCounts: c.severityCounts,
totalAtRiskMicros: c.totalAtRiskMicros,
generatedAt,
updatedAt: now,
})
.onConflictDoUpdate({
target: [tenantPortfolioRollup.tenantSlug, tenantPortfolioRollup.category],
set: {
count: c.count,
maxSeverity: c.maxSeverity,
severityCounts: c.severityCounts,
totalAtRiskMicros: c.totalAtRiskMicros,
generatedAt,
updatedAt: now,
},
});
upserted += 1;
}
});
} catch (err) {
console.error('[ingest/portfolio-rollup] upsert failed', {
tenantSlug,
message: err instanceof Error ? err.message : String(err),
});
return NextResponse.json({ error: 'ingest failed' }, { status: 500 });
}

return NextResponse.json({ ok: true, upserted }, { status: 200 });
}
39 changes: 39 additions & 0 deletions src/db/migrations/0025_tenant_portfolio_rollup.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-- Cross-tenant Foundry Situational-Awareness portfolio rollup β€” receiver table.
--
-- Each Atlas tenant pushes its OWN PII-free portfolio rollup here daily; the platform
-- is the RECEIVER and aggregates across tenants. The per-tenant push cron lives in the
-- atlas repo (/api/internal/portfolio-rollup-report). Frozen wire contract:
--
-- POST /api/platform/ingest/portfolio-rollup (header x-ingest-secret)
-- { tenantSlug, generatedAt,
-- rollup: { categories: [ { category, count, maxSeverity,
-- severityCounts: { critical, warn, info, unknown }, totalAtRiskUsd } ] } }
--
-- PII-FREE by construction: per-category counts + max severity + an at-risk-$ SUM only β€”
-- never an entity id, contact, or per-record value. total_at_risk_micros stores the $ SUM
-- as integer micros (mirrors tenant_llm_usage.cost_micros); null when no signal carried a value.
--
-- Idempotency: a re-push for a tenant fully REPLACES that tenant's rows β€” the ingest route
-- DELETEs the tenant's rows then INSERTs the fresh per-category set inside one transaction;
-- the unique index below also backstops duplicate (tenant_slug, category) via onConflictDoUpdate.
--
-- CRDB constraint: CREATE TABLE and CREATE INDEX are kept as separate statements.

CREATE TABLE IF NOT EXISTS tenant_portfolio_rollup (
id UUID NOT NULL DEFAULT gen_random_uuid() PRIMARY KEY,
tenant_slug TEXT NOT NULL,
category TEXT NOT NULL,
count INT8 NOT NULL DEFAULT 0,
max_severity TEXT,
severity_counts JSONB NOT NULL,
total_at_risk_micros INT8,
generated_at TIMESTAMPTZ NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT tenant_portfolio_rollup_category_check
CHECK (category IN ('health', 'ingest', 'intelGaps', 'focus')),
CONSTRAINT tenant_portfolio_rollup_max_severity_check
CHECK (max_severity IS NULL OR max_severity IN ('critical', 'warn', 'info', 'unknown'))
);

CREATE UNIQUE INDEX IF NOT EXISTS tenant_portfolio_rollup_unique
ON tenant_portfolio_rollup (tenant_slug, category);
7 changes: 7 additions & 0 deletions src/db/migrations/meta/_journal.json
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,13 @@
"when": 1781999999000,
"tag": "0024_tenant_llm_usage",
"breakpoints": true
},
{
"idx": 20,
"version": "7",
"when": 1782000100000,
"tag": "0025_tenant_portfolio_rollup",
"breakpoints": true
}
]
}
28 changes: 28 additions & 0 deletions src/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -206,3 +206,31 @@ export const tenantLlmUsage = pgTable('tenant_llm_usage', {

export type TenantLlmUsage = typeof tenantLlmUsage.$inferSelect;
export type NewTenantLlmUsage = typeof tenantLlmUsage.$inferInsert;

// Cross-tenant Foundry Situational-Awareness portfolio rollup β€” receiver table.
// Each Atlas tenant pushes its OWN PII-free portfolio rollup here daily (atlas
// repo: /api/internal/portfolio-rollup-report cron). The platform is the RECEIVER
// and aggregates across tenants. Frozen wire contract (header x-ingest-secret):
// POST /api/platform/ingest/portfolio-rollup
// { tenantSlug, generatedAt, rollup: { categories: [ { category, count,
// maxSeverity, severityCounts: {critical,warn,info,unknown}, totalAtRiskUsd } ] } }
// The payload is PII-FREE by construction (per-category counts + max severity +
// an at-risk-$ SUM β€” never an entity, contact, or per-record value). Idempotent:
// a re-push REPLACES that tenant's rows. total_at_risk_micros stores the $ SUM as
// integer micros (mirrors tenant_llm_usage cost_micros) β€” null when absent.
export const tenantPortfolioRollup = pgTable('tenant_portfolio_rollup', {
id: uuid('id').defaultRandom().primaryKey(),
tenantSlug: text('tenant_slug').notNull(),
category: text('category').notNull(), // 'health' | 'ingest' | 'intelGaps' | 'focus' β€” CHECK in migration
count: bigint('count', { mode: 'number' }).notNull().default(0),
maxSeverity: text('max_severity'), // 'critical' | 'warn' | 'info' | 'unknown' | null (count===0)
severityCounts: jsonb('severity_counts').notNull(), // { critical, warn, info, unknown }
totalAtRiskMicros: bigint('total_at_risk_micros', { mode: 'number' }), // $ SUM Γ— 1e6; null when absent
generatedAt: timestamp('generated_at', { withTimezone: true }).notNull(),
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
}, (table) => [
unique('tenant_portfolio_rollup_unique').on(table.tenantSlug, table.category),
]);

export type TenantPortfolioRollup = typeof tenantPortfolioRollup.$inferSelect;
export type NewTenantPortfolioRollup = typeof tenantPortfolioRollup.$inferInsert;
2 changes: 1 addition & 1 deletion src/lib/version.ts
Original file line number Diff line number Diff line change
@@ -1 +1 @@
export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? 'v2.27.0';
export const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION ?? 'v2.28.0';
Loading