From 05f394eaf97d2028173b4aad18749fc583c304b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 6 Nov 2026 10:40:25 +0100 Subject: [PATCH 1/3] feat(compliance): responsible gambling state, flags queue and kyc queue agent tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three read tools under compliance:view for an operator's assistant: - rg.player_state serves one player's responsible-gambling standing as RG_STANDING_READER reports it: restricted, the exclusions in force, the self-exclusions on record, the open flags, the limits with a raise or removal still waiting, and completed money over 1 to 4 windows priced into the pivot currency. No reason and no admin. It resolves the reader per call, so a kernel built without play eligibility still serves the other tools. - rg.flags_queue counts the active monitoring flags per type and in the last 7 days, and lists the oldest (1 to 20) by player id, without the flag's detail. - kyc.queue reads each player's latest verification per tier, counts those pending and awaiting a resubmission per tier with the average pending wait, and lists the oldest pending (1 to 20) by player id, without a decision reason, document or risk signal. The two queues join the player profile through its read-only schema. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- .../compliance-agent-registration.test.ts | 9 +- .../compliance-queue-tools.contract.test.ts | 50 ++++ .../compliance-queue-tools.int.test.ts | 254 ++++++++++++++++++ .../__tests__/rg-player-state.test.ts | 94 +++++++ .../src/compliance/contract/agent-tools.ts | 222 ++++++++++++++- packages/core/src/compliance/plugin.ts | 35 ++- .../service/compliance-queues.service.ts | 166 ++++++++++++ .../service/rg-player-state.service.ts | 35 +++ 8 files changed, 861 insertions(+), 4 deletions(-) create mode 100644 packages/core/src/compliance/__tests__/compliance-queue-tools.contract.test.ts create mode 100644 packages/core/src/compliance/__tests__/compliance-queue-tools.int.test.ts create mode 100644 packages/core/src/compliance/__tests__/rg-player-state.test.ts create mode 100644 packages/core/src/compliance/service/compliance-queues.service.ts create mode 100644 packages/core/src/compliance/service/rg-player-state.service.ts diff --git a/packages/core/src/compliance/__tests__/compliance-agent-registration.test.ts b/packages/core/src/compliance/__tests__/compliance-agent-registration.test.ts index ef15ed667..5aade91c3 100644 --- a/packages/core/src/compliance/__tests__/compliance-agent-registration.test.ts +++ b/packages/core/src/compliance/__tests__/compliance-agent-registration.test.ts @@ -3,13 +3,18 @@ import { Container, ModuleRegistryImpl, type CoreTokenCatalog } from '@openora/c import compliancePlugin from '../plugin.js'; describe('compliance agent surface registration', () => { - it('registers kyc.status and request_enhanced_kyc within the kernel contract rules', async () => { + it('registers its read tools and request_enhanced_kyc within the kernel contract rules', async () => { const registry = new ModuleRegistryImpl(new Container()); registry.setOwner('compliance'); await compliancePlugin.register(registry); - expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual(['kyc.status']); + expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual([ + 'kyc.status', + 'rg.player_state', + 'rg.flags_queue', + 'kyc.queue', + ]); expect(registry.actions.getAll().map((action) => action.contract.id)).toEqual([ 'request_enhanced_kyc', ]); diff --git a/packages/core/src/compliance/__tests__/compliance-queue-tools.contract.test.ts b/packages/core/src/compliance/__tests__/compliance-queue-tools.contract.test.ts new file mode 100644 index 000000000..eefb5f00c --- /dev/null +++ b/packages/core/src/compliance/__tests__/compliance-queue-tools.contract.test.ts @@ -0,0 +1,50 @@ +import { describe, it, expect } from 'vitest'; +import * as z from 'zod'; +import { + KycQueueInputSchema, + RgFlagsQueueInputSchema, + RgPlayerStateInputSchema, +} from '../contract/index.js'; + +const PLAYER_ID = '11111111-1111-4111-8111-111111111111'; + +describe('rg.player_state input', () => { + const parse = (input: unknown) => z.safeParse(RgPlayerStateInputSchema, input); + + it('defaults to the 7, 30 and 90 day windows', () => { + expect(parse({ playerId: PLAYER_ID })).toEqual({ + success: true, + data: { playerId: PLAYER_ID, windowDays: [7, 30, 90] }, + }); + }); + + it('takes 1 to 4 windows of 1 to 365 days, and a player id only', () => { + expect(parse({ playerId: PLAYER_ID, windowDays: ['1', 365] }).success).toBe(true); + expect(parse({ playerId: PLAYER_ID, windowDays: [] }).success).toBe(false); + expect(parse({ playerId: PLAYER_ID, windowDays: [1, 2, 3, 4, 5] }).success).toBe(false); + expect(parse({ playerId: PLAYER_ID, windowDays: [366] }).success).toBe(false); + expect(parse({ playerId: 'p_abcdefghijklmnop' }).success).toBe(false); + }); +}); + +describe('rg.flags_queue and kyc.queue input', () => { + it('default to the 10 oldest and take a limit from 1 to 20', () => { + expect(z.safeParse(RgFlagsQueueInputSchema, {})).toEqual({ + success: true, + data: { limit: 10 }, + }); + expect(z.safeParse(KycQueueInputSchema, {})).toEqual({ success: true, data: { limit: 10 } }); + expect(z.safeParse(RgFlagsQueueInputSchema, { limit: '20' }).success).toBe(true); + expect(z.safeParse(RgFlagsQueueInputSchema, { limit: 21 }).success).toBe(false); + expect(z.safeParse(KycQueueInputSchema, { limit: 0 }).success).toBe(false); + }); + + it('narrow only by a flag type or a tier they know', () => { + expect(z.safeParse(RgFlagsQueueInputSchema, { flagType: 'session_time' }).success).toBe(true); + expect(z.safeParse(RgFlagsQueueInputSchema, { flagType: 'chasing_losses' }).success).toBe( + false, + ); + expect(z.safeParse(KycQueueInputSchema, { tier: 'advanced' }).success).toBe(true); + expect(z.safeParse(KycQueueInputSchema, { tier: 'premium' }).success).toBe(false); + }); +}); diff --git a/packages/core/src/compliance/__tests__/compliance-queue-tools.int.test.ts b/packages/core/src/compliance/__tests__/compliance-queue-tools.int.test.ts new file mode 100644 index 000000000..d52b952d2 --- /dev/null +++ b/packages/core/src/compliance/__tests__/compliance-queue-tools.int.test.ts @@ -0,0 +1,254 @@ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; +import { randomUUID } from 'node:crypto'; +import { sql } from 'drizzle-orm'; +import { + Container, + DRIZZLE, + EVENT_BUS, + ModuleRegistryImpl, + findOneOrThrow, + type CoreTokenCatalog, +} from '@openora/core/server'; +import { + AUDIT_WRITER, + IDENTITY_READER, + KYC_ADAPTER, + KYC_STATUS_WRITER, + type KycAdapter, + type KycStatus, + type KycStatusWriter, + type KycTier, + type RunContext, +} from '@openora/core/contracts'; +import { createMcpKernel, createTestDb, type TestDb } from '@openora/core/testing'; +import { player } from '@openora/core/pam/schema/profile'; +import { migrate as migrateProfile } from '@openora/core/pam/migrate/profile'; +import { makeAuditWriter, makeEventBus, makeIdentityReader, mock } from '../../testing/mock.js'; +import { migrate } from '../migrate.js'; +import { kycVerification, rgFlag } from '../schema/index.js'; +import compliancePlugin from '../plugin.js'; + +let db: TestDb; + +const HOUR_MS = 60 * 60 * 1000; +const hoursAgo = (hours: number) => new Date(Date.now() - hours * HOUR_MS); +const DECISION_REASON = 'name on the ID does not match the account'; + +function bootKernel() { + const container = new Container(); + container.register(DRIZZLE, () => db.drizzle); + container.register(EVENT_BUS, () => makeEventBus()); + container.register(AUDIT_WRITER, () => makeAuditWriter()); + container.register(IDENTITY_READER, () => makeIdentityReader()); + container.register(KYC_ADAPTER, () => mock({})); + container.register(KYC_STATUS_WRITER, () => + mock({ setStatus: vi.fn(async () => null) }), + ); + const registry = new ModuleRegistryImpl(container); + registry.setOwner('compliance'); + compliancePlugin.register(registry); + return createMcpKernel({ + tools: registry.mcp.getTools(), + actions: registry.actions.getAll(), + container, + authorize: async () => 'allowed', + audit: makeAuditWriter(), + }); +} + +const runContext = (): RunContext => ({ + runId: randomUUID(), + actor: { kind: 'agent', agentId: randomUUID(), agentVersion: 1, onBehalfOf: randomUUID() }, + catalogVersion: 'test', + correlationId: 'compliance-queue-tools', +}); + +async function seedPlayer() { + return findOneOrThrow( + await db.drizzle.db.insert(player).values({ userId: randomUUID() }).returning(), + new Error('seedPlayer: query returned no row'), + ); +} + +async function seedFlag(values: Partial & { userId: string }) { + return findOneOrThrow( + await db.drizzle.db + .insert(rgFlag) + .values({ + flagType: 'limit_threshold', + limitType: 'deposit', + detail: { trigger: 'free text that must stay out', kind: 'self_exclusion' }, + ...values, + }) + .returning(), + new Error('seedFlag: query returned no row'), + ); +} + +async function seedVerification(userId: string, tier: KycTier, status: KycStatus, createdAt: Date) { + return findOneOrThrow( + await db.drizzle.db + .insert(kycVerification) + .values({ + userId, + provider: 'vendor', + referenceId: `ref-${randomUUID()}`, + tier, + status, + documentTypes: ['passport'], + decisionReason: DECISION_REASON, + triggeredBy: 'submission', + submittedAt: createdAt, + createdAt, + }) + .returning(), + new Error('seedVerification: query returned no row'), + ); +} + +beforeAll(async () => { + db = await createTestDb([migrate, migrateProfile]); +}); + +afterAll(async () => { + await db.drop(); +}); + +beforeEach(async () => { + await db.drizzle.db.execute( + sql`TRUNCATE ${rgFlag}, ${kycVerification}, ${player} RESTART IDENTITY CASCADE`, + ); +}); + +describe('rg.flags_queue through the MCP kernel (real PG)', () => { + it('counts the active flags by type and lists the oldest by player id, with no flag detail', async () => { + const kernel = bootKernel(); + const [oldest, newer] = [await seedPlayer(), await seedPlayer()]; + const first = await seedFlag({ userId: oldest.userId, flaggedAt: hoursAgo(30) }); + const second = await seedFlag({ + userId: newer.userId, + flagType: 'session_time', + limitType: null, + flaggedAt: hoursAgo(2), + }); + await seedFlag({ userId: newer.userId, status: 'cleared', flaggedAt: hoursAgo(400) }); + + const result = await kernel.invokeTool('rg.flags_queue', { limit: 5 }, runContext()); + + expect(result).toEqual({ + ok: true, + output: { + activeCount: 2, + activeByType: [ + { flagType: 'limit_threshold', count: 1 }, + { flagType: 'session_time', count: 1 }, + ], + flaggedLast7Days: 2, + oldest: [ + { + flagId: first.id, + playerId: oldest.id, + flagType: 'limit_threshold', + limitType: 'deposit', + flaggedAt: first.flaggedAt.toISOString(), + waitHours: expect.closeTo(30, 0), + }, + { + flagId: second.id, + playerId: newer.id, + flagType: 'session_time', + limitType: null, + flaggedAt: second.flaggedAt.toISOString(), + waitHours: expect.closeTo(2, 0), + }, + ], + }, + }); + const served = JSON.stringify(result); + expect(served).not.toContain('free text'); + expect(served).not.toContain(oldest.userId); + }); + + it('narrows the oldest to one flag type, and refuses a limit out of bounds', async () => { + const kernel = bootKernel(); + const row = await seedPlayer(); + await seedFlag({ userId: row.userId, flaggedAt: hoursAgo(10) }); + const session = await seedFlag({ + userId: row.userId, + flagType: 'session_time', + limitType: null, + flaggedAt: hoursAgo(5), + }); + + const narrowed = await kernel.invokeTool( + 'rg.flags_queue', + { flagType: 'session_time' }, + runContext(), + ); + expect(narrowed).toMatchObject({ ok: true, output: { activeCount: 2 } }); + expect(narrowed).toMatchObject({ ok: true, output: { oldest: [{ flagId: session.id }] } }); + + expect(await kernel.invokeTool('rg.flags_queue', { limit: 21 }, runContext())).toMatchObject({ + ok: false, + error: 'invalid_input', + }); + }); +}); + +describe('kyc.queue through the MCP kernel (real PG)', () => { + it("counts each player's latest verification per tier and lists the oldest pending ones", async () => { + const kernel = bootKernel(); + const [superseded, waiting, resubmitting] = [ + await seedPlayer(), + await seedPlayer(), + await seedPlayer(), + ]; + // An older pending attempt the player's later verification superseded waits for nothing. + await seedVerification(superseded.userId, 'basic', 'pending', hoursAgo(100)); + await seedVerification(superseded.userId, 'basic', 'approved', hoursAgo(50)); + const advanced = await seedVerification(waiting.userId, 'advanced', 'pending', hoursAgo(20)); + const basic = await seedVerification(waiting.userId, 'basic', 'pending', hoursAgo(10)); + await seedVerification(resubmitting.userId, 'basic', 'resubmission_requested', hoursAgo(5)); + + const result = await kernel.invokeTool('kyc.queue', {}, runContext()); + + expect(result).toEqual({ + ok: true, + output: { + pendingCount: 2, + resubmissionRequestedCount: 1, + byTier: [ + { tier: 'basic', pending: 1, resubmissionRequested: 1 }, + { tier: 'advanced', pending: 1, resubmissionRequested: 0 }, + ], + avgPendingWaitHours: expect.closeTo(15, 0), + oldest: [ + { + verificationId: advanced.id, + playerId: waiting.id, + tier: 'advanced', + status: 'pending', + triggeredBy: 'submission', + documentTypes: ['passport'], + submittedAt: advanced.submittedAt.toISOString(), + waitHours: expect.closeTo(20, 0), + }, + expect.objectContaining({ verificationId: basic.id, tier: 'basic' }), + ], + }, + }); + expect(JSON.stringify(result)).not.toContain(DECISION_REASON); + }); + + it('narrows the oldest pending to one tier', async () => { + const kernel = bootKernel(); + const row = await seedPlayer(); + await seedVerification(row.userId, 'advanced', 'pending', hoursAgo(20)); + const basic = await seedVerification(row.userId, 'basic', 'pending', hoursAgo(10)); + + const result = await kernel.invokeTool('kyc.queue', { tier: 'basic', limit: 1 }, runContext()); + + expect(result).toMatchObject({ ok: true, output: { oldest: [{ verificationId: basic.id }] } }); + expect(result).toMatchObject({ ok: true, output: { pendingCount: 2 } }); + }); +}); diff --git a/packages/core/src/compliance/__tests__/rg-player-state.test.ts b/packages/core/src/compliance/__tests__/rg-player-state.test.ts new file mode 100644 index 000000000..d8ade9d17 --- /dev/null +++ b/packages/core/src/compliance/__tests__/rg-player-state.test.ts @@ -0,0 +1,94 @@ +import { describe, it, expect } from 'vitest'; +import { toRgPlayerState } from '../service/rg-player-state.service.js'; + +const PLAYER_ID = '11111111-1111-4111-8111-111111111111'; + +describe('toRgPlayerState', () => { + it('serves the standing with ISO times, a money limit with its currency and a session one in minutes', () => { + const state = toRgPlayerState(PLAYER_ID, { + restricted: true, + activeExclusions: [ + { + kind: 'cooling_off', + startsAt: new Date('2026-11-01T10:00:00.000Z'), + expiresAt: new Date('2026-11-08T10:00:00.000Z'), + isPermanent: false, + }, + ], + selfExclusionsOnRecord: 1, + lastSelfExclusionEndedAt: new Date('2026-06-01T00:00:00.000Z'), + lastCoolingOffEndedAt: null, + activeFlags: [ + { + flagType: 'limit_threshold', + limitType: 'deposit', + flaggedAt: new Date('2026-11-02T08:00:00.000Z'), + }, + ], + limits: [ + { + type: 'deposit', + period: 'weekly', + amount: '500', + currency: 'EUR', + pendingKind: 'increase', + pendingRequestedAt: new Date('2026-11-03T09:00:00.000Z'), + }, + { + type: 'session', + period: 'session', + minutes: 90, + pendingKind: null, + pendingRequestedAt: null, + }, + ], + pivotCurrency: 'USD', + windows: [{ days: 7, totals: { deposits: '120.5', bets: '300', wins: '210' } }], + }); + + expect(state).toEqual({ + playerId: PLAYER_ID, + restricted: true, + activeExclusions: [ + { + kind: 'cooling_off', + startsAt: '2026-11-01T10:00:00.000Z', + expiresAt: '2026-11-08T10:00:00.000Z', + isPermanent: false, + }, + ], + selfExclusionsOnRecord: 1, + lastSelfExclusionEndedAt: '2026-06-01T00:00:00.000Z', + lastCoolingOffEndedAt: null, + activeFlags: [ + { + flagType: 'limit_threshold', + limitType: 'deposit', + flaggedAt: '2026-11-02T08:00:00.000Z', + }, + ], + limits: [ + { + type: 'deposit', + period: 'weekly', + amount: '500', + currency: 'EUR', + minutes: null, + pendingKind: 'increase', + pendingRequestedAt: '2026-11-03T09:00:00.000Z', + }, + { + type: 'session', + period: 'session', + amount: null, + currency: null, + minutes: 90, + pendingKind: null, + pendingRequestedAt: null, + }, + ], + pivotCurrency: 'USD', + windows: [{ days: 7, totals: { deposits: '120.5', bets: '300', wins: '210' } }], + }); + }); +}); diff --git a/packages/core/src/compliance/contract/agent-tools.ts b/packages/core/src/compliance/contract/agent-tools.ts index c373fd060..ceae6d919 100644 --- a/packages/core/src/compliance/contract/agent-tools.ts +++ b/packages/core/src/compliance/contract/agent-tools.ts @@ -1,13 +1,23 @@ import * as z from 'zod'; import { + ExclusionKindSchema, KycStatusSchema, + KycTierSchema, + LimitChangeKindSchema, + LimitPeriodSchema, + LimitTypeSchema, + MAX_RG_STANDING_WINDOW_DAYS, + MAX_RG_STANDING_WINDOWS, + MoneyAmountSchema, NonEmptyReasonSchema, + RG_FLAG_TYPES, + RgFlagTypeSchema, TimestampSchema, UuidSchema, defineActionType, defineMcpTool, } from '@openora/core/contracts'; -import { KycDocumentTypeSchema, KycTriggeredBySchema } from './enums.js'; +import { KYC_DOCUMENT_TYPES, KycDocumentTypeSchema, KycTriggeredBySchema } from './enums.js'; export const KycStandingVerificationSchema = z.object({ status: KycStatusSchema, @@ -78,3 +88,213 @@ export const requestEnhancedKycAction = defineActionType({ payloadSchema: RequestEnhancedKycPayloadSchema, errors: ['player_not_found', 'already_requested', 'verification_in_progress'], }); + +/** At most this many queued items a queue tool lists, oldest first. */ +export const COMPLIANCE_QUEUE_MAX_ITEMS = 20; + +export const RgPlayerStateInputSchema = z.object({ + playerId: UuidSchema, + windowDays: z + .array(z.coerce.number().int().min(1).max(MAX_RG_STANDING_WINDOW_DAYS)) + .min(1) + .max(MAX_RG_STANDING_WINDOWS) + .default([7, 30, 90]), +}); +export type RgPlayerStateInput = z.infer; + +export const RgPlayerStateOutputSchema = z.object({ + playerId: UuidSchema, + restricted: z.boolean(), + activeExclusions: z + .array( + z.object({ + kind: ExclusionKindSchema, + startsAt: TimestampSchema, + expiresAt: TimestampSchema.nullable(), + isPermanent: z.boolean(), + }), + ) + .max(4), + selfExclusionsOnRecord: z.number().int().nonnegative(), + lastSelfExclusionEndedAt: TimestampSchema.nullable(), + lastCoolingOffEndedAt: TimestampSchema.nullable(), + activeFlags: z + .array( + z.object({ + flagType: RgFlagTypeSchema, + limitType: LimitTypeSchema.nullable(), + flaggedAt: TimestampSchema, + }), + ) + .max(50), + limits: z + .array( + z.object({ + type: LimitTypeSchema, + period: LimitPeriodSchema, + /** Money limits: the amount and its currency; null for the session limit. */ + amount: MoneyAmountSchema.nullable(), + currency: z.string().nullable(), + /** The session limit's minutes; null for a money limit. */ + minutes: z.number().int().nullable(), + pendingKind: LimitChangeKindSchema.nullable(), + pendingRequestedAt: TimestampSchema.nullable(), + }), + ) + .max(50), + pivotCurrency: z.string(), + windows: z + .array( + z.object({ + days: z.number().int(), + totals: z + .object({ deposits: MoneyAmountSchema, bets: MoneyAmountSchema, wins: MoneyAmountSchema }) + .nullable(), + }), + ) + .max(MAX_RG_STANDING_WINDOWS), +}); +export type RgPlayerState = z.infer; + +export const rgPlayerStateTool = defineMcpTool({ + id: 'rg.player_state', + title: 'Responsible gambling state', + description: + "A player's responsible-gambling standing, as facts with no conclusion drawn: restricted is " + + 'whether their wagers are refused now; activeExclusions are the self-exclusions and ' + + 'cooling-offs in force (expiresAt null for a permanent one); selfExclusionsOnRecord counts ' + + 'every self-exclusion they ever had, with when the last self-exclusion and cooling-off ' + + 'ended; activeFlags are the open monitoring flags; limits are the deposit, wager, loss and ' + + 'session limits as they apply now, with a raise or removal still waiting its cool-down; and ' + + 'windows are completed deposits, stakes and wins over each of windowDays (1 to 4 windows of 1 to 365 days, default 7, 30 and 90) priced into pivotCurrency, totals null when an amount cannot be priced. Amounts are decimal strings. No reason, note or admin is returned.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'compliance', action: 'view' }, + inputSchema: RgPlayerStateInputSchema, + outputSchema: RgPlayerStateOutputSchema, + redact: { + allow: [ + 'playerId', + 'restricted', + 'activeExclusions', + 'selfExclusionsOnRecord', + 'lastSelfExclusionEndedAt', + 'lastCoolingOffEndedAt', + 'activeFlags', + 'limits', + 'pivotCurrency', + 'windows', + ], + }, + errors: ['player_not_found'], +}); + +export const RgFlagsQueueInputSchema = z.object({ + flagType: RgFlagTypeSchema.optional(), + limit: z.coerce.number().int().min(1).max(COMPLIANCE_QUEUE_MAX_ITEMS).default(10), +}); +export type RgFlagsQueueInput = z.infer; + +export const RgFlagsQueueOutputSchema = z.object({ + activeCount: z.number().int().nonnegative(), + activeByType: z + .array(z.object({ flagType: RgFlagTypeSchema, count: z.number().int().positive() })) + .max(RG_FLAG_TYPES.length), + flaggedLast7Days: z.number().int().nonnegative(), + oldest: z + .array( + z.object({ + flagId: UuidSchema, + playerId: UuidSchema.nullable(), + flagType: RgFlagTypeSchema, + limitType: LimitTypeSchema.nullable(), + flaggedAt: TimestampSchema, + waitHours: z.number().nonnegative(), + }), + ) + .max(COMPLIANCE_QUEUE_MAX_ITEMS), +}); +export type RgFlagsQueue = z.infer; + +export const rgFlagsQueueTool = defineMcpTool({ + id: 'rg.flags_queue', + title: 'Responsible gambling flags queue', + description: + 'The open responsible-gambling monitoring flags across all players: how many are active, ' + + 'per flag type (limit_threshold: a player near or past a limit; session_time: a long ' + + 'session; self_excluded_login: a login attempt while self-excluded), how many were raised in ' + + 'the last 7 days, and the active ones waiting longest, oldest first (limit 1 to 20, default ' + + '10; flagType narrows them), each with its flagId, playerId, flag type, the limit it is ' + + "about and hours open. Use rg.player_state for one player's standing.", + class: 'read', + schemaVersion: 1, + iam: { resource: 'compliance', action: 'view' }, + inputSchema: RgFlagsQueueInputSchema, + outputSchema: RgFlagsQueueOutputSchema, + redact: { allow: ['activeCount', 'activeByType', 'flaggedLast7Days', 'oldest'] }, + errors: [], +}); + +export const KycQueueInputSchema = z.object({ + tier: KycTierSchema.optional(), + limit: z.coerce.number().int().min(1).max(COMPLIANCE_QUEUE_MAX_ITEMS).default(10), +}); +export type KycQueueInput = z.infer; + +export const KycQueueOutputSchema = z.object({ + pendingCount: z.number().int().nonnegative(), + resubmissionRequestedCount: z.number().int().nonnegative(), + byTier: z + .array( + z.object({ + tier: KycTierSchema, + pending: z.number().int().nonnegative(), + resubmissionRequested: z.number().int().nonnegative(), + }), + ) + .max(2), + avgPendingWaitHours: z.number().nonnegative().nullable(), + oldest: z + .array( + z.object({ + verificationId: UuidSchema, + playerId: UuidSchema.nullable(), + tier: KycTierSchema, + status: KycStatusSchema, + triggeredBy: KycTriggeredBySchema, + documentTypes: z.array(KycDocumentTypeSchema).max(KYC_DOCUMENT_TYPES.length), + submittedAt: TimestampSchema, + waitHours: z.number().nonnegative(), + }), + ) + .max(COMPLIANCE_QUEUE_MAX_ITEMS), +}); +export type KycQueue = z.infer; + +export const kycQueueTool = defineMcpTool({ + id: 'kyc.queue', + title: 'KYC queue', + description: + "The KYC verifications waiting across all players, each player's latest per tier: how many " + + 'are pending (awaiting a decision) and how many wait for the player to resubmit, per tier ' + + '(basic, or advanced - enhanced KYC), the average hours the pending ones have waited, and the ' + + 'pending ones waiting longest, oldest first (limit 1 to 20, default 10; tier narrows them), ' + + 'each with its verificationId, playerId, tier, status, what triggered it, the document types ' + + "submitted and hours waited. Use kyc.status for one player's standing. No decision reason, " + + 'document or personal data is returned.', + class: 'read', + schemaVersion: 1, + iam: { resource: 'compliance', action: 'view' }, + inputSchema: KycQueueInputSchema, + outputSchema: KycQueueOutputSchema, + redact: { + allow: [ + 'pendingCount', + 'resubmissionRequestedCount', + 'byTier', + 'avgPendingWaitHours', + 'oldest', + ], + }, + errors: [], +}); diff --git a/packages/core/src/compliance/plugin.ts b/packages/core/src/compliance/plugin.ts index be0870035..0ce838d94 100644 --- a/packages/core/src/compliance/plugin.ts +++ b/packages/core/src/compliance/plugin.ts @@ -40,8 +40,16 @@ import { type JobQueueAdapter, type RealtimeTransport, } from '@openora/core/contracts'; -import { kycStatusTool, requestEnhancedKycAction } from './contract/index.js'; +import { + kycQueueTool, + kycStatusTool, + requestEnhancedKycAction, + rgFlagsQueueTool, + rgPlayerStateTool, +} from './contract/index.js'; import { ComplianceService } from './service/compliance.service.js'; +import { ComplianceQueuesService } from './service/compliance-queues.service.js'; +import { toRgPlayerState } from './service/rg-player-state.service.js'; import { KycResubmissionAlreadyRequestedError, KycVerificationInProgressError, @@ -213,6 +221,31 @@ export default { }; }); + ctx.mcp.tool(rgPlayerStateTool, (c) => { + const identity = c.get(IDENTITY_READER); + return async ({ playerId, windowDays }) => { + // Resolved per call: the reader's own dependencies (play eligibility, rates) are another + // module's, and a kernel built without them still serves compliance's other tools. + const standing = c.get(RG_STANDING_READER); + const userId = await userIdOfPlayer(identity, playerId); + const state = userId ? await standing.getStanding(userId, { windowDays }) : null; + if (!state) { + throw new McpToolError('player_not_found'); + } + return toRgPlayerState(playerId, state); + }; + }); + + ctx.mcp.tool(rgFlagsQueueTool, (c) => { + const queues = new ComplianceQueuesService(c.get(DRIZZLE)); + return (input) => queues.rgFlagsQueue(input); + }); + + ctx.mcp.tool(kycQueueTool, (c) => { + const queues = new ComplianceQueuesService(c.get(DRIZZLE)); + return (input) => queues.kycQueue(input); + }); + ctx.actions.register(requestEnhancedKycAction, (c) => { const identity = c.get(IDENTITY_READER); const kyc = kycVerification(c); diff --git a/packages/core/src/compliance/service/compliance-queues.service.ts b/packages/core/src/compliance/service/compliance-queues.service.ts new file mode 100644 index 000000000..e695d23e1 --- /dev/null +++ b/packages/core/src/compliance/service/compliance-queues.service.ts @@ -0,0 +1,166 @@ +import { and, asc, count, desc, eq, gte, inArray, sql } from '@openora/core/server/orm'; +import type { DrizzleService } from '@openora/core/server'; +import { + KycTierSchema, + LimitTypeSchema, + type KycStatus, + type KycTier, + type LimitType, +} from '@openora/core/contracts'; +import { player } from '@openora/core/pam/schema/profile'; +import { + KycDocumentTypeSchema, + type KycQueue, + type KycQueueInput, + type RgFlagsQueue, + type RgFlagsQueueInput, +} from '../contract/index.js'; +import { kycVerification, rgFlag } from '../schema/index.js'; + +const HOUR_MS = 60 * 60 * 1000; +const RECENT_FLAG_DAYS = 7; + +/** Hours from `from` to `now`, to one decimal, never negative. */ +const hoursBetween = (from: Date, now: Date) => + Math.round(Math.max(0, (now.getTime() - from.getTime()) / HOUR_MS) * 10) / 10; + +/** A flag's limit as it was stored, kept only when it is a limit type compliance knows. */ +const limitTypeOf = (value: string | null): LimitType | null => + LimitTypeSchema.safeParse(value).data ?? null; + +/** A KYC status the queue counts: awaiting a decision, or waiting for the player to resubmit. */ +const QUEUED_KYC_STATUSES = ['pending', 'resubmission_requested'] as const satisfies KycStatus[]; + +/** + * The compliance queues an agent reads across all players: the open responsible-gambling flags + * and the KYC verifications waiting. Each names a player by player id only, and returns no + * reason, note, document or personal data. + */ +export class ComplianceQueuesService { + constructor(private readonly drizzle: DrizzleService) {} + + async rgFlagsQueue( + { flagType, limit }: RgFlagsQueueInput, + now: Date = new Date(), + ): Promise { + const db = this.drizzle.db; + const since = new Date(now.getTime() - RECENT_FLAG_DAYS * 24 * HOUR_MS); + const [byType, recent, oldest] = await Promise.all([ + db + .select({ flagType: rgFlag.flagType, count: count() }) + .from(rgFlag) + .where(eq(rgFlag.status, 'active')) + .groupBy(rgFlag.flagType) + .orderBy(desc(count()), asc(rgFlag.flagType)), + db.select({ count: count() }).from(rgFlag).where(gte(rgFlag.flaggedAt, since)), + db + .select({ + flagId: rgFlag.id, + playerId: player.id, + flagType: rgFlag.flagType, + limitType: rgFlag.limitType, + flaggedAt: rgFlag.flaggedAt, + }) + .from(rgFlag) + .leftJoin(player, eq(player.userId, rgFlag.userId)) + .where( + and(eq(rgFlag.status, 'active'), flagType ? eq(rgFlag.flagType, flagType) : undefined), + ) + .orderBy(asc(rgFlag.flaggedAt), asc(rgFlag.id)) + .limit(limit), + ]); + return { + activeCount: byType.reduce((sum, row) => sum + row.count, 0), + activeByType: byType, + flaggedLast7Days: recent[0]?.count ?? 0, + oldest: oldest.map((row) => ({ + flagId: row.flagId, + playerId: row.playerId, + flagType: row.flagType, + limitType: limitTypeOf(row.limitType), + flaggedAt: row.flaggedAt.toISOString(), + waitHours: hoursBetween(row.flaggedAt, now), + })), + }; + } + + /** + * Counts each player's latest verification per tier: an older one a later attempt superseded + * waits for nothing. + */ + async kycQueue({ tier, limit }: KycQueueInput, now: Date = new Date()): Promise { + const db = this.drizzle.db; + const latest = db + .selectDistinctOn([kycVerification.userId, kycVerification.tier], { + id: kycVerification.id, + userId: kycVerification.userId, + tier: kycVerification.tier, + status: kycVerification.status, + triggeredBy: kycVerification.triggeredBy, + documentTypes: kycVerification.documentTypes, + submittedAt: kycVerification.submittedAt, + }) + .from(kycVerification) + .orderBy(kycVerification.userId, kycVerification.tier, desc(kycVerification.createdAt)) + .as('latest'); + const waitHours = sql< + number | null + >`avg(extract(epoch from (${now.toISOString()}::timestamptz - ${latest.submittedAt})) / 3600)`.mapWith( + (value: string | null) => (value === null ? null : Number(value)), + ); + const [counts, oldest] = await Promise.all([ + db + .select({ tier: latest.tier, status: latest.status, count: count(), waitHours }) + .from(latest) + .where(inArray(latest.status, [...QUEUED_KYC_STATUSES])) + .groupBy(latest.tier, latest.status), + db + .select({ + verificationId: latest.id, + playerId: player.id, + tier: latest.tier, + status: latest.status, + triggeredBy: latest.triggeredBy, + documentTypes: latest.documentTypes, + submittedAt: latest.submittedAt, + }) + .from(latest) + .leftJoin(player, eq(player.userId, latest.userId)) + .where(and(eq(latest.status, 'pending'), tier ? eq(latest.tier, tier) : undefined)) + .orderBy(asc(latest.submittedAt), asc(latest.id)) + .limit(limit), + ]); + const countOf = (rowTier: KycTier, status: KycStatus) => + counts.find((row) => row.tier === rowTier && row.status === status)?.count ?? 0; + const pending = counts.filter((row) => row.status === 'pending'); + const pendingCount = pending.reduce((sum, row) => sum + row.count, 0); + const pendingWait = pending.reduce((sum, row) => sum + (row.waitHours ?? 0) * row.count, 0); + return { + pendingCount, + resubmissionRequestedCount: counts + .filter((row) => row.status === 'resubmission_requested') + .reduce((sum, row) => sum + row.count, 0), + byTier: KycTierSchema.options + .map((rowTier) => ({ + tier: rowTier, + pending: countOf(rowTier, 'pending'), + resubmissionRequested: countOf(rowTier, 'resubmission_requested'), + })) + .filter((row) => row.pending + row.resubmissionRequested > 0), + avgPendingWaitHours: + pendingCount === 0 ? null : Math.round((pendingWait / pendingCount) * 10) / 10, + oldest: oldest.map((row) => ({ + verificationId: row.verificationId, + playerId: row.playerId, + tier: row.tier, + status: row.status, + triggeredBy: row.triggeredBy, + documentTypes: row.documentTypes.filter( + (type) => KycDocumentTypeSchema.safeParse(type).success, + ), + submittedAt: row.submittedAt.toISOString(), + waitHours: hoursBetween(row.submittedAt, now), + })), + }; + } +} diff --git a/packages/core/src/compliance/service/rg-player-state.service.ts b/packages/core/src/compliance/service/rg-player-state.service.ts new file mode 100644 index 000000000..da23bbba4 --- /dev/null +++ b/packages/core/src/compliance/service/rg-player-state.service.ts @@ -0,0 +1,35 @@ +import type { Player, RgStanding } from '@openora/core/contracts'; +import type { RgPlayerState } from '../contract/index.js'; + +/** The RG standing reader's facts as the `rg.player_state` tool serves them: dates as ISO times. */ +export function toRgPlayerState(playerId: Player['id'], standing: RgStanding): RgPlayerState { + return { + playerId, + restricted: standing.restricted, + activeExclusions: standing.activeExclusions.map((exclusion) => ({ + kind: exclusion.kind, + startsAt: exclusion.startsAt.toISOString(), + expiresAt: exclusion.expiresAt?.toISOString() ?? null, + isPermanent: exclusion.isPermanent, + })), + selfExclusionsOnRecord: standing.selfExclusionsOnRecord, + lastSelfExclusionEndedAt: standing.lastSelfExclusionEndedAt?.toISOString() ?? null, + lastCoolingOffEndedAt: standing.lastCoolingOffEndedAt?.toISOString() ?? null, + activeFlags: standing.activeFlags.map((flag) => ({ + flagType: flag.flagType, + limitType: flag.limitType, + flaggedAt: flag.flaggedAt.toISOString(), + })), + limits: standing.limits.map((limit) => ({ + type: limit.type, + period: limit.period, + amount: limit.type === 'session' ? null : limit.amount, + currency: limit.type === 'session' ? null : limit.currency, + minutes: limit.type === 'session' ? limit.minutes : null, + pendingKind: limit.pendingKind, + pendingRequestedAt: limit.pendingRequestedAt?.toISOString() ?? null, + })), + pivotCurrency: standing.pivotCurrency, + windows: standing.windows, + }; +} From 33a3f1f5ba175e3169ab5174abf965adcacd333e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 6 Nov 2026 10:40:26 +0100 Subject: [PATCH 2/3] test(testing): cover the compliance read tools end to end through the mcp kernel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The kernel suite expects the three compliance tools and calls each against seeded rows: exactly the allow-listed keys, the player by player id, no exclusion or decision reason, flag detail or user id in what is served, player_not_found for an unknown player, invalid_input for a limit out of bounds, and forbidden - audited once - for an admin whose role lacks compliance:view. The transport suite lists them under their model names. Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- .../src/__tests__/mcp-kernel.e2e.test.ts | 166 +++++++++++++++++- .../src/__tests__/mcp-transport.e2e.test.ts | 3 + 2 files changed, 168 insertions(+), 1 deletion(-) diff --git a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts index d5286e919..cda817ab3 100644 --- a/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts +++ b/packages/testing/src/__tests__/mcp-kernel.e2e.test.ts @@ -15,7 +15,7 @@ import { type RunContext, } from '@openora/core/contracts'; import { auditLog } from '@openora/core/audit/schema'; -import { kycVerification } from '@openora/core/compliance/schema'; +import { kycVerification, rgExclusion, rgFlag, userLimit } from '@openora/core/compliance/schema'; import { user } from '@openora/core/pam/schema/identity'; import { playerTag, tag } from '@openora/core/pam/schema/tag'; import { wallet, walletTransaction } from '@openora/core/wallet/schema'; @@ -46,9 +46,12 @@ const NOTE_ROUTE_KEYS = ['actorId', 'content', 'createdAt', 'id', 'playerId', 'u const EXPECTED_TOOL_OWNERS = { 'financial.summary': 'analytics', 'ggr.summary': 'analytics', + 'kyc.queue': 'compliance', 'kyc.status': 'compliance', 'payments.flow_daily': 'analytics', 'player.summary': 'player-management', + 'rg.flags_queue': 'compliance', + 'rg.player_state': 'compliance', 'players.deposit_compare': 'wallet', 'wallet.activity': 'wallet', 'withdrawals.queue': 'wallet', @@ -1327,3 +1330,164 @@ describe('operator read tools answer only their declared keys, under their own g expect(rows[0]).toMatchObject({ action: 'mcp.tool.failed', resourceId: toolId }); }); }); + +describe('compliance read tools serve RG and KYC facts by player id, under compliance:view', () => { + // Far enough back that no other suite's flag or verification is older. + const LONG_AGO = '2020-01-02T03:04:05.000Z'; + const EXCLUSION_REASON = 'told support they cannot stop'; + const DECISION_REASON = 'selfie does not match the passport photo'; + let marketer: Awaited>; + let flagged: Awaited>; + let flagId: string; + let verificationId: string; + + beforeAll(async () => { + marketer = await newStaff(app, 'mcp-no-compliance', 'marketing-banners'); + flagged = await newPlayer('rg-flagged'); + await drizzle().insert(userLimit).values({ + userId: flagged.userId, + type: 'deposit', + amount: '250', + currency: 'USD', + period: 'weekly', + }); + await drizzle() + .insert(rgExclusion) + .values({ + userId: flagged.userId, + kind: 'cooling_off', + reason: EXCLUSION_REASON, + expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + createdBy: flagged.userId, + }); + const [flag] = await drizzle() + .insert(rgFlag) + .values({ + userId: flagged.userId, + flagType: 'limit_threshold', + limitType: 'deposit', + detail: { actual: '240', limit: '250', period: 'weekly', pct: 96 }, + flaggedAt: new Date(LONG_AGO), + }) + .returning({ id: rgFlag.id }); + const [verification] = await drizzle() + .insert(kycVerification) + .values({ + userId: flagged.userId, + provider: 'vendor', + referenceId: `mcp-kyc-queue-${randomUUID()}`, + tier: 'advanced', + status: 'pending', + documentTypes: ['passport'], + decisionReason: DECISION_REASON, + triggeredBy: 'submission', + submittedAt: new Date(LONG_AGO), + createdAt: new Date(LONG_AGO), + }) + .returning({ id: kycVerification.id }); + flagId = flag?.id ?? ''; + verificationId = verification?.id ?? ''; + }); + + it("answers rg.player_state with the player's exclusion, flag and limit, and no reason", async () => { + const output = outputOf( + await kernel.invokeTool( + 'rg.player_state', + { playerId: flagged.playerId, windowDays: [7] }, + adminRun(adminId), + ), + ); + + expect(Object.keys(output).sort()).toEqual(allowListOf('rg.player_state')); + expect(output).toMatchObject({ + playerId: flagged.playerId, + // Play eligibility reads the block the RG service sets with an exclusion; the row seeded + // here sets none, so only the shape is checked. + restricted: expect.any(Boolean), + activeExclusions: [{ kind: 'cooling_off', isPermanent: false }], + activeFlags: [{ flagType: 'limit_threshold', limitType: 'deposit', flaggedAt: LONG_AGO }], + limits: [ + expect.objectContaining({ + type: 'deposit', + period: 'weekly', + currency: 'USD', + minutes: null, + pendingKind: null, + }), + ], + windows: [{ days: 7 }], + }); + const served = JSON.stringify(output); + expect(served).not.toContain(EXCLUSION_REASON); + expect(served).not.toContain(flagged.userId); + expect(served).not.toContain(flagged.email); + + expect( + await kernel.invokeTool('rg.player_state', { playerId: randomUUID() }, adminRun(adminId)), + ).toMatchObject({ ok: false, error: 'player_not_found' }); + }); + + it('answers rg.flags_queue with the oldest active flag by player id, without its detail', async () => { + const output = outputOf( + await kernel.invokeTool('rg.flags_queue', { limit: 1 }, adminRun(adminId)), + ); + + expect(Object.keys(output).sort()).toEqual(allowListOf('rg.flags_queue')); + expect(listField(output, 'oldest')).toEqual([ + { + flagId, + playerId: flagged.playerId, + flagType: 'limit_threshold', + limitType: 'deposit', + flaggedAt: LONG_AGO, + waitHours: expect.any(Number), + }, + ]); + expect(Number(output['activeCount'])).toBeGreaterThanOrEqual(1); + expect(JSON.stringify(output)).not.toContain(flagged.userId); + }); + + it('answers kyc.queue with the oldest pending verification by player id, without its reason', async () => { + const output = outputOf( + await kernel.invokeTool('kyc.queue', { tier: 'advanced', limit: 1 }, adminRun(adminId)), + ); + + expect(Object.keys(output).sort()).toEqual(allowListOf('kyc.queue')); + expect(listField(output, 'oldest')).toEqual([ + { + verificationId, + playerId: flagged.playerId, + tier: 'advanced', + status: 'pending', + triggeredBy: 'submission', + documentTypes: ['passport'], + submittedAt: LONG_AGO, + waitHours: expect.any(Number), + }, + ]); + const served = JSON.stringify(output); + expect(served).not.toContain(DECISION_REASON); + expect(served).not.toContain(flagged.userId); + + expect(await kernel.invokeTool('kyc.queue', { limit: 21 }, adminRun(adminId))).toMatchObject({ + ok: false, + error: 'invalid_input', + }); + }); + + it.each([ + ['rg.player_state', () => ({ playerId: flagged.playerId })], + ['rg.flags_queue', () => ({})], + ['kyc.queue', () => ({})], + ] as const)('refuses %s to an admin whose roles lack compliance:view', async (toolId, input) => { + const run = adminRun(marketer.userId); + + expect(await kernel.invokeTool(toolId, input(), run)).toEqual({ + ok: false, + error: 'forbidden', + }); + const rows = await auditRowsOfCall(run.correlationId); + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ action: 'mcp.tool.failed', resourceId: toolId }); + }); +}); diff --git a/packages/testing/src/__tests__/mcp-transport.e2e.test.ts b/packages/testing/src/__tests__/mcp-transport.e2e.test.ts index c4d8f5e8a..9160768d9 100644 --- a/packages/testing/src/__tests__/mcp-transport.e2e.test.ts +++ b/packages/testing/src/__tests__/mcp-transport.e2e.test.ts @@ -305,10 +305,13 @@ describe('MCP transport, happy path through the SDK client', () => { expect(tools.map((tool) => tool.name).sort()).toEqual([ 'financial_summary', 'ggr_summary', + 'kyc_queue', 'kyc_status', 'payments_flow_daily', 'player_summary', 'players_deposit_compare', + 'rg_flags_queue', + 'rg_player_state', 'wallet_activity', 'withdrawals_queue', ]); From cf2642e370b5b3fcb091fd2a01c247bf99639aae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Pa=C5=9B?= Date: Fri, 6 Nov 2026 10:40:27 +0100 Subject: [PATCH 3/3] docs(core): record the compliance read tools MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Signed-off-by: Mateusz Paś --- ...42-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md index 8a64e3a32..c9784a0b3 100644 --- a/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md +++ b/docs/adr/0042-mcp-kernel-two-registries-behind-one-enforcing-wrapper.md @@ -17,6 +17,8 @@ > **Update (2026-11-05)**: Core registers operator-level read tools beside the per-player ones, so an admin's assistant can answer questions about the whole operation: `payments.flow_daily` and `financial.summary` (analytics, `analytics:view`), `withdrawals.queue` (`withdrawal:view`) and `players.deposit_compare` (`transaction:view`). Each bounds its date range or windows, caps every list it returns and says so in its description, and returns none of the free text an admin or a check wrote (a review reason, a note, a decision reason), no address, hash or provider reference, and no name or email. A tool output names a player only by player id, under a key named `playerId` or `playerIds` or ending in `PlayerId` or `PlayerIds`: a consumer pseudonymises players by key name, so a player id under another key, or a user id under any key, would reach the model as it is. `players.deposit_compare` reads its population through `PLAYER_SEGMENT_READER`, refuses a segment above 20 000 players, and resolves each page of player ids to user ids in one query with the optional `IDENTITY_READER.getUserIdsByPlayerIds`. The iam catalog gains `agent-chat:use`, which an agent hub checks before an admin may use its back-office assistant; core enforces nothing on it, grants it where it grants `mcp-access`, and every tool call is still checked against the asking admin's grant for that tool. +> **Update (2026-11-06)**: Compliance adds three read tools under `compliance:view`: `rg.player_state` (one player's responsible-gambling standing as `RG_STANDING_READER` reports it - exclusions in force, open flags, limits with a raise still waiting, and priced money windows - with no reason and no admin), `rg.flags_queue` (the open monitoring flags across all players, counted per type, the oldest by player id, without the flag's detail) and `kyc.queue` (each player's latest verification per tier waiting for a decision or a resubmission, the oldest pending by player id, without a decision reason, document or risk signal). The two queues join the player profile through its read-only schema, so they name a player by player id with no identity lookup per row. `rg.player_state` resolves the standing reader per call, so a kernel built without play eligibility still serves compliance's other tools. + ## Context An agent hub (a premium package outside this repo) runs models that read platform data and