Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
c938b50
fix(scaffold): make the split-service host set its name and boot
Oct 13, 2026
9671ce4
fix(core)!: apply the service manifest to any plugin list and name mi…
Oct 13, 2026
5ef3b5b
feat(core): choose which job queues a process consumes
Oct 13, 2026
33cab66
docs: propose that dependency-only modules provide but do not consume
Oct 13, 2026
cdb9c17
fix(core): start a worker that a router factory registers
Oct 14, 2026
5126e0c
feat(core): log the optional ports a boot leaves unbound
Oct 14, 2026
beea7f2
fix(scaffold): warn what a split service duplicates and skips
Oct 14, 2026
a9feeb9
Merge branch 'dev' into feat/service-split-seams
mp-blurify Oct 14, 2026
f1fc1c3
fix(core): fail a split boot closed on a control it would skip
Oct 16, 2026
c44cfe0
fix(core): name everything a split manifest leaves out in one boot error
Oct 16, 2026
2a2d009
fix(scaffold): give create:service an example that boots
Oct 16, 2026
3003452
docs: say how close a split stays to the monolith and how add-ons pic…
Oct 16, 2026
31d6090
Merge remote-tracking branch 'origin/dev' into feat/service-split-seams
Oct 19, 2026
160bf80
fix(core): register no plugin twice on a refused manifest boot and cl…
Oct 19, 2026
872eb6f
Merge branch 'dev' into feat/service-split-seams
mp-blurify Oct 20, 2026
6a9f966
docs(repo): renumber the job worker and dependency-only ADRs to 0045 …
Oct 20, 2026
9dd187f
Merge branch 'dev' into feat/service-split-seams
mp-blurify Oct 20, 2026
6260abc
Merge remote-tracking branch 'origin/dev' into feat/service-split-seams
Oct 22, 2026
949973c
docs(repo): renumber the service split adrs after the at-least-once d…
Oct 22, 2026
49bee37
Merge branch 'dev' into feat/service-split-seams
Oct 26, 2026
de747d5
Merge branch 'dev' into feat/service-split-seams
Oct 27, 2026
e2f19a3
Merge branch 'dev' into feat/service-split-seams
Oct 27, 2026
312da11
Merge branch 'dev' into feat/service-split-seams
Oct 30, 2026
77c086d
Merge branch 'dev' into feat/service-split-seams
Nov 2, 2026
3a6cf01
docs: renumber the job worker and dependency-only module ADRs past th…
Nov 2, 2026
8bd1962
fix(core): read flagged manifest entries in the split boot check
Nov 2, 2026
cbe4872
Merge branch 'dev' into feat/service-split-seams
Nov 5, 2026
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
2 changes: 1 addition & 1 deletion .rulesync/commands/scaffold-plugin.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ description: 'Generate a new overlay extension plugin under extensions/<name>/.

Run `pnpm gen plugin $ARGUMENTS` in the repo root. Variants: `pnpm gen adapter <name> <TOKEN> <dependsOn>` for a vendor-adapter swap; `pnpm gen job-worker <name>` for a background-job worker.

The generator creates `extensions/<name>/plugin.ts` and registers it in `extensions.config.ts`. Then implement `register(ctx)`:
The generator creates `extensions/<name>/plugin.ts` and registers it in `extensions.config.ts`. Then implement `register(ctx)`. It only declares: a timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`, because a failed `SERVICE_MANIFEST` boot registers plugins into a container it then drops (ADR-0049).

- `ctx.provide(TOKEN, factory)` - bind/override an adapter or service. Last registration wins, so an overlay loaded after the default-binding module replaces the default - keep the order in `extensions.config.ts` intentional. Sealed compliance tokens (`@openora/core/compliance`) are rejected at compile time + runtime.
- `ctx.routers.add(namespace, (c) => router)` - mount oRPC routes.
Expand Down
2 changes: 2 additions & 0 deletions .rulesync/rules/messaging-and-microservices.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,5 +37,7 @@ Use the seams and contracts in code; this rule owns channel selection and the sa
- An event reporting a committed change on a money, compliance, KYC, RG or account-status path uses `emitInTransaction(tx, ...)` inside the same transaction (the outbox is always on). `emit()` is best-effort and only for informational, catalog and cache-invalidation events. Both throw on an invalid payload.
- A throwing handler is redelivered and dead-lettered to `oss:dlq:<topic>` after 5 deliveries (ADR-0045); plugin handlers are deduped per consumer via `processed_events`, a bare `bus.on` without a `consumer` is not.
- A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. A subscription that must run once per deployment, however many services load its plugin (the audit writer), passes `{ consumerGroup }` as the third argument of `ctx.events.on`; services of one deployment using the same group name compete for each event, and `createApp` scopes the name as `<REALTIME_NAMESPACE>/<group>` so two deployments sharing one Redis do not.
- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot logs each `optionalPorts` entry left unbound with what its module skips. A split fails closed on controls: it refuses to boot while a selected module's `controlPorts` entry (an RG limit, a geo check, an audit record) or `AdminGuard`'s permission resolver or security policy - checked on every manifest boot, which builds the guard before it serves - is unbound and the plugin list has the module that binds it. Whatever is missing fails the boot once, with one manifest line adding every module needed and theirs; with the shipped modules every split carries iam, identity, audit, mail, compliance, wallet, gaming, player-management, exchange-rate and chat. Without a manifest nothing changes.
- `JOB_WORKERS` picks the job queues a process consumes (default all; names, `prefix*`, `!` to exclude). To keep a long-running queue off the API replicas, run an extra full instance with the same `SERVICE_NAME` and `JOB_WORKERS=<queues>`, and give the replicas `JOB_WORKERS=!<queues>`. Processes sharing a `SERVICE_NAME` load the same modules, every queue needs a consumer somewhere, and a handler never assumes it runs where its job was enqueued.
- The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required.
- `REALTIME_TRANSPORT` is Redis Pub/Sub (`RedisPubSubRealtimeTransport`, auto-bound on `REDIS_URL`, no in-process fallback - ADR-0031) - fire-and-forget UI push, not a durable event: a message published while nobody is subscribed is simply lost, which is why a realtime channel carries a change _signal_ the client refetches, never the state itself. Channels, signals, presence and the revocation channel are prefixed with `REALTIME_NAMESPACE`, which defaults to `SERVICE_NAME`. The two are opposite requirements: `SERVICE_NAME` must differ per service (consumer group), `REALTIME_NAMESPACE` must match across the services that push to the same clients, and must differ between deployments sharing one Redis. A service that sets `SERVICE_MANIFEST` must set both, or the boot fails. A manifest entry takes one flag, `id:no-routes`: the module still binds its ports, handles its events and runs its jobs, but its router is discarded, so it serves no HTTP route. Its router factory still runs, because several modules construct the service their events and jobs read, or schedule their jobs, inside it (the audit writer assigns `svcRef` there); a factory that guards the HTTP surface takes `options.mounted` and skips that guard when it is `false`. Use it for a module a kept module `dependsOn` but whose surface the service must not expose (identity's sign-in, whose country gate lives in the compliance module). A manifest that names identity must name audit too, since identity `dependsOn` it, or the boot fails on the missing dependency. An unknown flag, or the flag on an infra overlay or an unknown id, fails the boot. `RealtimePresence`/`getOnlineUserIds` are shared across replicas via `RedisPresenceStore`, a per-channel sorted set scored by each member's last heartbeat and read with a TTL cutoff, so a crashed replica's members self-expire instead of lingering forever.
2 changes: 2 additions & 0 deletions .rulesync/subagents/plugin-author.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,8 @@ ctx.mcp.tool(defineMcpTool({...}), (c) => async (input, run) => output); // agen
ctx.actions.register(defineActionType({...}), (c) => ({ precondition, execute })); // action type: a change a model may only propose
```

`register()` only declares into `ctx`. A timer, connection, file or network read, process listener, global or `process.env` write goes in a `provide()` or router factory, closed with `c.onDispose()`: a failed `SERVICE_MANIFEST` boot registers the plugins it has not loaded into a container it then drops, to learn which binds which port (ADR-0049).

Agent tools and action types are served only through the `MCP_KERNEL` token, which checks the IAM grant of the admin behind the run, validates input, returns only the allow-listed output keys and declared error codes, and audits every tool call and action execution. A shape it cannot serve (eg a tool input that is not a top-level `z.object`, or an unbounded string) fails boot with the fix. `execute` must perform nothing on a replay while its effect still holds, and answer `already_applied`. The one-argument `ctx.mcp.tool(definition)` still registers but the kernel does not serve it. Contract types: `packages/core/src/contracts/adapters/mcp.ts`; why: ADR-0042.

No decorators, no controllers - `{ id, dependsOn, register } satisfies Plugin<CoreTokenCatalog>` wired by the functional Container (ADR-0009). DB tables: a `pgTable` in the plugin's own `schema/index.ts`, then `pnpm regen`.
Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ Use the seams and contracts in code; this rule owns channel selection and the sa
- An event reporting a committed change on a money, compliance, KYC, RG or account-status path uses `emitInTransaction(tx, ...)` inside the same transaction (the outbox is always on). `emit()` is best-effort and only for informational, catalog and cache-invalidation events. Both throw on an invalid payload.
- A throwing handler is redelivered and dead-lettered to `oss:dlq:<topic>` after 5 deliveries (ADR-0045); plugin handlers are deduped per consumer via `processed_events`, a bare `bus.on` without a `consumer` is not.
- A Redis Streams deployment uses `SERVICE_NAME` as its durable consumer-group identity. Every independently deployed service sharing Redis needs a distinct name. A subscription that must run once per deployment, however many services load its plugin (the audit writer), passes `{ consumerGroup }` as the third argument of `ctx.events.on`; services of one deployment using the same group name compete for each event, and `createApp` scopes the name as `<REALTIME_NAMESPACE>/<group>` so two deployments sharing one Redis do not.
- `SERVICE_MANIFEST` picks the modules a process boots; `createApp` applies it to any plugin list. A manifest lists every module its modules `dependsOn` and every module that binds a port they require; infra overlays always load and their dependencies count. Boot logs each `optionalPorts` entry left unbound with what its module skips. A split fails closed on controls: it refuses to boot while a selected module's `controlPorts` entry (an RG limit, a geo check, an audit record) or `AdminGuard`'s permission resolver or security policy - checked on every manifest boot, which builds the guard before it serves - is unbound and the plugin list has the module that binds it. Whatever is missing fails the boot once, with one manifest line adding every module needed and theirs; with the shipped modules every split carries iam, identity, audit, mail, compliance, wallet, gaming, player-management, exchange-rate and chat. Without a manifest nothing changes.
- `JOB_WORKERS` picks the job queues a process consumes (default all; names, `prefix*`, `!` to exclude). To keep a long-running queue off the API replicas, run an extra full instance with the same `SERVICE_NAME` and `JOB_WORKERS=<queues>`, and give the replicas `JOB_WORKERS=!<queues>`. Processes sharing a `SERVICE_NAME` load the same modules, every queue needs a consumer somewhere, and a handler never assumes it runs where its job was enqueued.
- The shipped Redis Streams and BullMQ drivers do not honor `orderingKey`; use an overlay when strict ordering is required.
- `REALTIME_TRANSPORT` is Redis Pub/Sub (`RedisPubSubRealtimeTransport`, auto-bound on `REDIS_URL`, no in-process fallback - ADR-0031) - fire-and-forget UI push, not a durable event: a message published while nobody is subscribed is simply lost, which is why a realtime channel carries a change _signal_ the client refetches, never the state itself. Channels, signals, presence and the revocation channel are prefixed with `REALTIME_NAMESPACE`, which defaults to `SERVICE_NAME`. The two are opposite requirements: `SERVICE_NAME` must differ per service (consumer group), `REALTIME_NAMESPACE` must match across the services that push to the same clients, and must differ between deployments sharing one Redis. A service that sets `SERVICE_MANIFEST` must set both, or the boot fails. A manifest entry takes one flag, `id:no-routes`: the module still binds its ports, handles its events and runs its jobs, but its router is discarded, so it serves no HTTP route. Its router factory still runs, because several modules construct the service their events and jobs read, or schedule their jobs, inside it (the audit writer assigns `svcRef` there); a factory that guards the HTTP surface takes `options.mounted` and skips that guard when it is `false`. Use it for a module a kept module `dependsOn` but whose surface the service must not expose (identity's sign-in, whose country gate lives in the compliance module). A manifest that names identity must name audit too, since identity `dependsOn` it, or the boot fails on the missing dependency. An unknown flag, or the flag on an infra overlay or an unknown id, fails the boot. `RealtimePresence`/`getOnlineUserIds` are shared across replicas via `RedisPresenceStore`, a per-channel sorted set scored by each member's last heartbeat and read with a TTL cutoff, so a crashed replica's members self-expire instead of lingering forever.

Expand Down
26 changes: 16 additions & 10 deletions docs/adapters/error-tracking.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,19 +63,25 @@ export default {
if (!dsn) {
return; // self-disabling: no DSN -> platform stays logs-only
}
Sentry.init({ dsn });
ctx.provide(ERROR_TRACKING, () => ({
captureException(error, context) {
Sentry.captureException(error, {
...(context?.userId ? { user: { id: context.userId } } : {}),
tags: { ...context?.tags, ...(context?.traceId ? { trace_id: context.traceId } : {}) },
...(context?.extra ? { extra: context.extra } : {}),
});
},
}));
ctx.provide(ERROR_TRACKING, () => {
Sentry.init({ dsn });
return {
captureException(error, context) {
Sentry.captureException(error, {
...(context?.userId ? { user: { id: context.userId } } : {}),
tags: { ...context?.tags, ...(context?.traceId ? { trace_id: context.traceId } : {}) },
...(context?.extra ? { extra: context.extra } : {}),
});
},
};
});
},
} as const satisfies Plugin<CoreTokenCatalog>;
```

`Sentry.init` runs in the factory, which `createApp` resolves once for the container that serves,
not in `register()`: a failed `SERVICE_MANIFEST` boot registers plugins into a container it then
drops (ADR-0049). Initialising in a file preloaded with `node --import` works as well.

Register it last in `extensions.config.ts` (a `kind: 'infra'` overlay). A PostHog/Rollbar shop
writes the equivalent overlay against the same port - core is unchanged.
2 changes: 2 additions & 0 deletions docs/adr/0014-job-queue-and-realtime-transport-seams.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@
> **Update (2026-07-25)**: [ADR-0039](./0039-tests-run-the-production-seams.md) deleted `InProcessJobQueue` - the test suite binds `BullMqJobQueue` too. `InProcessRealtimeTransport` stays, but as `createApp`'s production default rather than a test double, since core ships no realtime driver. The seam shapes are unchanged.
>
> **Update (2026-08-31)**: [ADR-0031](./0031-realtime-transport-sse-and-http-over-web-sockets.md) shipped `RedisPubSubRealtimeTransport` and deleted `InProcessRealtimeTransport` too - `REALTIME_TRANSPORT` now auto-binds on `REDIS_URL` exactly like `JOB_QUEUE`, with no in-process fallback either. Both seams from this ADR are now fully superseded on the production-default question; the seam shapes and the client-push-is-not-inter-module decision are still unchanged.
>
> **Update (2026-10-13)**: a worker still starts at boot against the resolved `JOB_QUEUE`, unless `JOB_WORKERS` leaves its queue to another process ([ADR-0049](./0049-per-process-job-worker-selection.md)).

## Context

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
**Status**: Accepted; implemented (manifest boot + outbox seam + `WALLET_COMMANDS` reference; outbox off by default).
**Relates to**: ADR-0010 (event-driven broker direction), ADR-0014 (job-queue/realtime seams), ADR-0016 (event envelope).

> **Update (2026-10-13)**: `createApp` now applies `SERVICE_MANIFEST` to whatever plugin list it is given, so a host that passes a static list gets the same subset as one that calls `loadExtensions()`; until then such a host booted every module under the split service's own consumer group. `loadExtensions()` no longer filters, and `apps/api/src/extensions.ts` is long gone. A manifest must list every module its modules depend on and every module that binds a port they require - the dependencies of infra overlays included. It also fails closed on controls: a selected module's `controlPorts`, and `AdminGuard`'s permission resolver and security policy on every manifest boot, must be bound whenever the plugin list has the module that binds them. Whatever is missing, the boot fails once with one manifest line that adds it all ([ADR-0049](./0049-per-process-job-worker-selection.md)). `pnpm create:service` now sets `SERVICE_NAME` too. Which job queues a process consumes is chosen apart from its modules: [ADR-0049](./0049-per-process-job-worker-selection.md).

> **Update (2026-10-13)**: [ADR-0045](./0045-at-least-once-event-delivery.md) made the outbox always on - `OUTBOX_ENABLED` is gone, `createApp` always binds `OUTBOX` and starts the `OutboxRelay`, and money and compliance events are emitted with `emitInTransaction`. The "Opt-in" paragraph below no longer holds. The relay now claims rows atomically with a lease, and consumers dedup per handler through `processed_events` instead of on `eventId` alone.

## Context
Expand Down
2 changes: 2 additions & 0 deletions docs/adr/0033-analytics-as-a-read-only-domain.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
**Date**: 2026-07-25
**Status**: Accepted

> **Update (2026-10-13)**: `SERVICE_MANIFEST=analytics` alone does not boot: the manifest must also list the modules analytics depends on (wallet, identity, profile, gaming) and theirs. The boot names every missing one ([ADR-0017](./0017-extraction-readiness-manifest-outbox-command-ports.md)).

## Context

A financial-analytics dashboard is requested (deposits/withdrawals by
Expand Down
4 changes: 4 additions & 0 deletions docs/adr/0036-rg-limit-enforcement-and-cooldown.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ refused server-side with a typed error. The paragraph below asserting the admin
"still writes a limit outright in either direction" no longer holds; everything else in
this ADR (the enforcement points, the deposit/wager gates, the player-path cool-down,
and the concurrency reasoning) is unchanged.
**Update (2026-10-16)**: `RG_LIMITS` stays optional for its consumers in a full boot, but wallet
and gaming list it in `controlPorts`, so a `SERVICE_MANIFEST` boot that loads either refuses to
start without the compliance module while the plugin list has it: another process of the install
enforces the limits there ([ADR-0049](./0049-per-process-job-worker-selection.md)).

> **Update (2026-10-15)**: RG spend now reads the wallet ledger for every product, and `loss`
> stays unenforced for the reason in [ADR-0046](./0046-sportsbook-ledger-port.md).
Expand Down
Loading
Loading