Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@

> **Update (2026-11-03)**: Every audit row of one proposal's execution carries the run's correlation id, the module's rows as well as the kernel's. The kernel runs the executor (not the precondition) inside a request context that carries the id - the active request's, keeping its trace id, caller and audit trail, or a fresh one when the execution runs outside a request. `AUDIT_WRITER` fills an entry's unset `correlationId` from that context (an explicit value, `null` included, wins), and the event bus copies it into the event envelope and the outbox row, so a row the audit module writes later from an event emitted by the executor carries it too; a topic that maps a bulk operation id keeps that id. The id is not in the hash chain, as before.

> **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.

## Context

An agent hub (a premium package outside this repo) runs models that read platform data and
Expand Down
5 changes: 4 additions & 1 deletion docs/modules/wallet.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@ does not repeat it.
containers, the sweep, reconciliation findings, and the withdrawal approval path.
- **Its agent surface.** A read tool over a player's balances, recent money movement and
withdrawals still awaiting a decision, and an action type that holds a player's waiting
withdrawal for manual review. Both are served only through the MCP kernel (ADR-0042).
withdrawal for manual review. Two operator-level read tools: the withdrawal queue (its counts,
totals, average wait and oldest waiting withdrawals, with no review note, address or hash),
and a tagged segment's completed deposits compared between two windows, priced into the fx
pivot. All are served only through the MCP kernel (ADR-0042).
- **Read-only money facts for other modules.** Lifetime deposits and withdrawals, the last
deposit, turnover and recent withdrawal requests, read through the wallet's read port rather
than its tables. Every total is priced into the fx pivot at today's rates and reads as
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ import { Container, ModuleRegistryImpl, type CoreTokenCatalog } from '@openora/c
import analyticsPlugin from '../plugin.js';

describe('analytics agent surface registration', () => {
it('registers ggr.summary within the kernel contract rules', async () => {
it('registers its read tools within the kernel contract rules', async () => {
const registry = new ModuleRegistryImpl<CoreTokenCatalog>(new Container());
registry.setOwner('analytics');

await analyticsPlugin.register(registry);

expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual(['ggr.summary']);
expect(registry.mcp.getTools().map((tool) => tool.contract.id)).toEqual([
'ggr.summary',
'financial.summary',
'payments.flow_daily',
]);
expect(registry.actions.getAll().map((action) => action.contract.id)).toEqual([]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import * as z from 'zod';
import {
FINANCIAL_SUMMARY_MAX_DAYS,
FinancialSummaryInputSchema,
PAYMENTS_FLOW_MAX_DAYS,
PaymentsFlowInputSchema,
resolveFinancialSummaryRange,
resolvePaymentsFlowRange,
} from '../contract/index.js';

const DAY_MS = 24 * 60 * 60 * 1000;
const NOW = new Date('2026-03-15T12:00:00Z');
const RANGE_START = '2024-01-01';

const dayOffset = (day: string, days: number) =>
new Date(Date.parse(day) + days * DAY_MS).toISOString().slice(0, 10);

describe('payments.flow_daily input', () => {
const parse = (input: unknown) => z.safeParse(PaymentsFlowInputSchema, input);

it('defaults to day buckets over the 14 days ending on the day of now', () => {
expect(parse({})).toEqual({ success: true, data: { granularity: 'day' } });
expect(resolvePaymentsFlowRange({}, NOW)).toEqual({
dateFrom: '2026-03-02',
dateTo: '2026-03-15',
});
});

it(`accepts a range of exactly ${PAYMENTS_FLOW_MAX_DAYS} days and refuses one day more`, () => {
const longest = { dateFrom: RANGE_START, dateTo: dayOffset(RANGE_START, 91) };
const tooLong = { dateFrom: RANGE_START, dateTo: dayOffset(RANGE_START, 92) };

expect(parse(longest).success).toBe(true);
expect(parse(tooLong).error?.issues.map((issue) => issue.path)).toEqual([['dateFrom']]);
});

it('refuses a range that ends before it starts', () => {
const result = parse({ dateFrom: '2026-03-15', dateTo: '2026-03-14' });

expect(result.error?.issues.map((issue) => issue.path)).toEqual([['dateFrom']]);
});

it('takes only deposit and withdrawal types, each listed at most once in all', () => {
expect(parse({ types: ['withdrawal'] }).success).toBe(true);
expect(parse({ types: ['bet'] }).success).toBe(false);
expect(parse({ types: [] }).success).toBe(false);
expect(parse({ types: ['deposit', 'withdrawal', 'deposit'] }).success).toBe(false);
});

it('takes wallet transaction statuses only and day or week buckets only', () => {
expect(parse({ statuses: ['on_hold', 'refunded'] }).success).toBe(true);
expect(parse({ statuses: ['approved'] }).success).toBe(false);
expect(parse({ granularity: 'month' }).success).toBe(false);
});
});

describe('financial.summary input', () => {
const parse = (input: unknown) => z.safeParse(FinancialSummaryInputSchema, input);

it('defaults to the 30 days ending on the day of now', () => {
expect(resolveFinancialSummaryRange({}, NOW)).toEqual({
dateFrom: '2026-02-14',
dateTo: '2026-03-15',
});
});

it(`accepts a range of exactly ${FINANCIAL_SUMMARY_MAX_DAYS} days and refuses one day more`, () => {
const longest = { dateFrom: RANGE_START, dateTo: dayOffset(RANGE_START, 365) };
const tooLong = { dateFrom: RANGE_START, dateTo: dayOffset(RANGE_START, 366) };

expect(parse(longest).success).toBe(true);
expect(parse(tooLong).error?.issues.map((issue) => issue.path)).toEqual([['dateFrom']]);
});

it('refuses a currency that is not a ticker', () => {
expect(parse({ currency: 'US-DOLLAR' }).success).toBe(false);
});
});
Loading
Loading