@portalshq/capability-interaction-orchestrator 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,248 +1,616 @@
1
1
  # @portalshq/capability-interaction-orchestrator
2
2
 
3
- A durable interaction lifecycle for input windows, moderated submissions, selection,
4
- credit-backed billing, execution handoff and replayable events. PostgreSQL owns
5
- admission, quotas, idempotency, credit allocations, resolutions and event ordering.
3
+ **Reusable interaction orchestration for Portals:** input windows, submission gateway, arbitration engine, moderation gateway, billing controller, and event publisher.
6
4
 
7
- ## Setup
5
+ ## Status: Phase 1 (Stub Package) - NOT Production-Ready
8
6
 
9
- Requires Node.js 22+, PostgreSQL, and the `@portalshq/monetization` entitlement schema
10
- in the **same database**. Apply migrations with errors stopping the transaction:
7
+ This is currently a **stub package** providing TypeScript interfaces and type definitions. Implementations throw "not implemented" errors. Full implementation coming in Phase 2.
8
+
9
+ **Production Readiness:**
10
+ - **Phase 1 (Current):** NOT production-ready - stubs only
11
+ - **Phase 2:** Production-ready for basic use cases (FIFO, E-rated, prepaid credits)
12
+ - **Phase 3:** Production-ready for all use cases (all policies, all ratings, pluggable moderation)
13
+
14
+ ## Overview
15
+
16
+ The Interaction Orchestrator provides reusable infrastructure for managing user interactions in AI-driven entertainment experiences. It handles:
17
+
18
+ - **Input Windows** - Time-bounded opportunities for user interaction
19
+ - **Submission Gateway** - Validation, idempotency, and admission
20
+ - **Arbitration Engine** - Configurable contention resolution policies
21
+ - **Moderation Gateway** - Rating-based and pluggable content moderation
22
+ - **Billing Controller** - Integration with `@portalshq/monetization`
23
+ - **Event Publisher** - SSE/outbox for frontend signaling
24
+
25
+ ## Installation
26
+
27
+ ```bash
28
+ npm install @portalshq/monetization # Peer dependency
29
+ npm install @portalshq/capability-interaction-orchestrator
30
+ ```
31
+
32
+ ## Core Concepts
33
+
34
+ ### Input Windows
35
+
36
+ An Input Window represents a time-bounded opportunity for user interaction. Examples:
37
+ - A 30-second audience vote on a plot twist
38
+ - A 1-minute challenge round in a game show
39
+ - An always-open paid prompt queue (set `closesAt` to null)
40
+
41
+ **Window Lifecycle:**
42
+ ```
43
+ scheduled → open → qualifying → resolving → executing → committing → published
44
+ ```
45
+
46
+ **Key Properties:**
47
+ - `worldId` - Channel/world identifier
48
+ - `conflictKey` - Contention scoping (entity, branch, or session)
49
+ - `arbitrationPolicy` - How to resolve contention (FIFO, voting, etc.)
50
+ - `billingPolicy` - How to charge (free, pay-to-submit, etc.)
51
+ - `contentRating` - Moderation threshold (E, T, T+, M)
52
+ - `closesAt` - When window closes (null = always-open)
53
+
54
+ ### Submissions
55
+
56
+ A Submission is a user's proposed action against a specific target. Multiple submissions may compete for selection via arbitration.
57
+
58
+ **Key Properties:**
59
+ - `windowId` - Window this submission belongs to
60
+ - `actorId` - User/app identifier
61
+ - `idempotencyKey` - Retry safety
62
+ - `kind` - Type of interaction (vote, prompt, mutation, etc.)
63
+ - `payload` - Submission data
64
+ - `status` - Lifecycle state
65
+
66
+ ### Resolutions
67
+
68
+ A Resolution represents the outcome of arbitration: which submissions were selected and how they were combined into execution.
69
+
70
+ **Key Properties:**
71
+ - `selectedSubmissionIds` - IDs of selected submissions
72
+ - `rejectedSubmissionIds` - IDs of rejected submissions
73
+ - `arbitrationPolicy` - Policy used
74
+ - `resultingCommitHash` - Resulting world state
75
+
76
+ ### Arbitration Policies
77
+
78
+ The package supports 7 arbitration policies, configurable per window:
79
+
80
+ 1. **first_accepted** - Earliest qualifying submission wins (exclusive actions)
81
+ 2. **fifo** - Every qualifying input executes in order (paid story creation)
82
+ 3. **plurality** - Most votes wins (game shows)
83
+ 4. **ranked** - Rank by priority, then tie-break (featured submissions)
84
+ 5. **batch** - Combine compatible inputs (live narrative twists)
85
+ 6. **parallel_branches** - Every qualified input gets independent result (remix galleries)
86
+ 7. **host_selection** - Host manually selects (curated events)
87
+
88
+ ### Content Rating System
89
+
90
+ Based on DC Comics rating system:
91
+
92
+ - **E (Everyone)** - All ages, cartoonish action, no profanity/violence/sexual content
93
+ - **T (Teen)** - Ages 12+, mild violence/language/suggestive dialogue
94
+ - **T+ (Teen Plus)** - Ages 15+, moderate profanity, graphic action, mature storylines
95
+ - **M (Mature)** - Ages 17+, heavy sexual themes, nudity, extensive profanity, graphic violence
96
+
97
+ ### Billing Policies
98
+
99
+ - **free** - No payment required
100
+ - **pay_to_generate** - Payment before generation
101
+ - **pay_to_publish** - Payment after valid preview, before publication
102
+ - **pay_for_guaranteed_slot** - Payment for guaranteed submission/placement
103
+ - **capture_if_selected** - Authorize payment, capture only if selected
104
+ - **prepaid_credits** - Consume prepaid credits
105
+
106
+ ## Phase 1: Stub Package (Current)
107
+
108
+ **What exists:**
109
+ - TypeScript interfaces and types
110
+ - Stub implementations (throw "not implemented - Phase 2")
111
+ - Core database schema
112
+ - Basic documentation
113
+
114
+ **What's missing:**
115
+ - Actual implementations
116
+ - Database persistence layer
117
+ - Integration with `@portalshq/monetization`
118
+ - Integration with `@portalshq/capability-realtime-fanout-postgres`
119
+ - Tests
120
+
121
+ ## Phase 2: Core Implementation (Next)
122
+
123
+ **Will include:**
124
+ - Window Manager (in-process window closer via setInterval)
125
+ - Submission Gateway (validation, idempotency, admission)
126
+ - Arbitration Engine (FIFO policy)
127
+ - Moderation Gateway (E-rated, rule-based validators)
128
+ - Billing Controller (monetization integration)
129
+ - Event Publisher (fanout-postgres integration)
130
+ - PostgreSQL persistence layer
131
+ - Tests for implemented components
132
+ - Migration guide for replacing existing guides.ts
133
+
134
+ **Production-ready for:**
135
+ - Always-open queues (like current guides.ts)
136
+ - FIFO arbitration
137
+ - E-rated content
138
+ - Prepaid credit billing
139
+ - Basic SSE event publishing
140
+
141
+ **Estimated effort:** 60 hours
142
+
143
+ ## Phase 3: Full Feature Set
144
+
145
+ **Will add:**
146
+ - Remaining 6 arbitration policies (plurality, ranked, batch, etc.)
147
+ - T, T+, M rating validators
148
+ - Pluggable moderation interface for custom validators
149
+ - Advanced billing policies
150
+ - Complete documentation and examples
151
+ - 95% test coverage
152
+
153
+ **Estimated effort:** 44 hours
154
+
155
+ ## Database Schema
156
+
157
+ ### Core Schema (Package-Provided)
158
+
159
+ The package provides core tables in `sql/001_interaction_orchestrator_core.sql`:
160
+
161
+ - `input_windows` - Window definitions
162
+ - `submissions` - User submissions
163
+ - `resolutions` - Arbitration outcomes
164
+ - `moderation_decisions` - Moderation results
165
+ - `interaction_outbox` - Event queue
166
+
167
+ ### App Extensions (App-Provided)
168
+
169
+ Applications can extend core tables via:
170
+ - `app_metadata` JSONB column on all core tables
171
+ - Additional extension tables with foreign keys to core tables
172
+
173
+ **Example extension:**
174
+ ```sql
175
+ -- App-specific extension table
176
+ CREATE TABLE game_show_windows (
177
+ window_id text PRIMARY KEY REFERENCES input_windows(id),
178
+ round_number integer NOT NULL,
179
+ contestant_count integer NOT NULL
180
+ );
181
+ ```
182
+
183
+ ## Architecture
11
184
 
12
- ```sh
13
- psql "$DATABASE_URL" -v ON_ERROR_STOP=1 --single-transaction \
14
- -f node_modules/@portalshq/monetization/sql/001_monetization.sql \
15
- -f node_modules/@portalshq/capability-interaction-orchestrator/sql/001_interaction_orchestrator_core.sql \
16
- -f node_modules/@portalshq/capability-interaction-orchestrator/sql/002_durable_lifecycle.sql
17
185
  ```
186
+ ┌─────────────────────────────────────────────────────────────┐
187
+ │ Interaction Orchestrator Package │
188
+ │ (In-Process Only) │
189
+ ├─────────────────────────────────────────────────────────────┤
190
+ │ │
191
+ │ ┌──────────────────┐ ┌──────────────────┐ │
192
+ │ │ Window Manager │ │Submission Gateway│ │
193
+ │ │ - Lifecycle │ │ - Validation │ │
194
+ │ │ - Triggers │ │ - Idempotency │ │
195
+ │ │ - State Machine │ │ - Admission │ │
196
+ │ │ - setInterval │ │ │ │
197
+ │ └────────┬─────────┘ └────────┬─────────┘ │
198
+ │ │ │ │
199
+ │ └────────────┬───────────────┘ │
200
+ │ │ │
201
+ │ ┌────────────────────▼────────────────────┐ │
202
+ │ │ Moderation Gateway │ │
203
+ │ │ - Admission validation (built-in) │ │
204
+ │ │ - Content safety (rating-based) │ │
205
+ │ │ - Custom validators (pluggable) │ │
206
+ │ │ - E/T/T+/M rating system │ │
207
+ │ └────────────────────┬────────────────────┘ │
208
+ │ │ │
209
+ │ ┌────────────────────▼────────────────────┐ │
210
+ │ │ Arbitration Engine │ │
211
+ │ │ - FIFO, voting, branches, etc. │ │
212
+ │ │ - Configurable per window │ │
213
+ │ └────────────────────┬────────────────────┘ │
214
+ │ │ │
215
+ │ ┌────────────────────▼────────────────────┐ │
216
+ │ │ Billing Controller │ │
217
+ │ │ - Quote, authorize, capture │ │
218
+ │ │ - Integration with @portalshq/monetization│
219
+ │ └────────────────────┬────────────────────┘ │
220
+ │ │ │
221
+ │ ┌────────────────────▼────────────────────┐ │
222
+ │ │ Event Publisher │ │
223
+ │ │ - SSE events, outbox │ │
224
+ │ │ - Integration with fanout-postgres │ │
225
+ │ └─────────────────────────────────────────┘ │
226
+ │ │
227
+ └─────────────────────────────────────────────────────────────┘
228
+ ```
229
+
230
+ ## Ownership Boundaries
231
+
232
+ **Package owns:**
233
+ - Window lifecycle
234
+ - Admission gates
235
+ - Arbitration policies
236
+ - Event signaling
237
+ - Core database schema
238
+
239
+ **Application owns:**
240
+ - Moderation rules (custom validators)
241
+ - World constraints (custom validators)
242
+ - Model execution (production layer)
243
+ - Pricing (monetization catalog)
244
+ - Schema extensions (app_metadata, extension tables)
245
+ - Production layer workers (unchanged)
246
+
247
+ ## Peer Dependencies
248
+
249
+ - `@portalshq/monetization` - Billing, entitlements, ledger
250
+
251
+ ## Integration with Portals Packages
252
+
253
+ - `@portalshq/monetization` - Billing Controller integration
254
+ - `@portalshq/capability-realtime-fanout-postgres` - Event Publisher integration
255
+ - `@portalshq/capability-identity` - Actor ID verification
256
+ - `@portalshq/capability-live-production` - Window triggers integration
257
+ - `@portalshq/queue-broadcast` - Media staging integration
258
+
259
+ ## Migration Path for Bears Live App
260
+
261
+ **Phase 1:** Apply core schema, keep existing `guides.ts` unchanged
262
+
263
+ **Phase 2:**
264
+ - Rename `guides.ts` to `interaction-orchestrator-client.ts`
265
+ - Rewrite to use new package
266
+ - Configure as always-open window (no closesAt)
267
+ - Set content rating to "E"
268
+ - Test with existing payment flow
18
269
 
19
- Deploy the accompanying monetization update together with this package: both use
20
- ID order for grant locks while consuming the oldest matching credits first.
21
- The old `Stub*` exports have been removed. The executable classes now require their
22
- actual dependencies; no implementation silently grants a payment or host approval.
270
+ **Phase 3:** Add advanced features (voting, branches, etc.)
23
271
 
24
- ## Lifecycle interface
272
+ ## Development
273
+
274
+ ```bash
275
+ # Build
276
+ npm run build
277
+
278
+ # Type check
279
+ npm run typecheck
280
+
281
+ # Test (Phase 2+)
282
+ npm test
283
+
284
+ # Test with coverage (Phase 2+)
285
+ npm run test:coverage
286
+ ```
287
+
288
+ ## Database Setup
289
+
290
+ Apply the core schema to your PostgreSQL database:
291
+
292
+ ```bash
293
+ psql $DATABASE_URL -f node_modules/@portalshq/capability-interaction-orchestrator/sql/001_interaction_orchestrator_core.sql
294
+ ```
295
+
296
+ For Bears Live App migration, see the migration section below.
297
+
298
+ ## Migration from guides.ts to Interaction Orchestrator
299
+
300
+ ### Current State (guides.ts)
301
+
302
+ The existing `guides.ts` in Bears Live App provides:
303
+ - Simple FIFO queue for paid prompts
304
+ - 1 credit per prompt via `@portalshq/monetization`
305
+ - Basic validation and text model moderation
306
+ - Implicit always-open window (no explicit window management)
307
+ - Single arbitration policy (all eligible prompts used)
308
+
309
+ ### Migration Path
310
+
311
+ **Phase 1 (Current):**
312
+ - Apply core schema to Bears database
313
+ - Keep existing `guides.ts` unchanged
314
+ - No functional changes
315
+
316
+ **Phase 2:**
317
+ 1. Rename `guides.ts` to `interaction-orchestrator-client.ts`
318
+ 2. Import `WindowManager`, `SubmissionGateway`, etc. from new package
319
+ 3. Create an always-open window:
320
+ ```typescript
321
+ const window = await windowManager.createWindow({
322
+ worldId: channelId,
323
+ branchId: "canonical",
324
+ baseCommitHash: currentCommit,
325
+ conflictKey: channelId,
326
+ allowedInputKinds: ["custom_prompt"],
327
+ arbitrationPolicy: "fifo",
328
+ billingPolicy: "prepaid_credits",
329
+ maxSubmissionsPerUser: 1,
330
+ maxSelectedInputs: 5,
331
+ opensAt: new Date().toISOString(),
332
+ closesAt: null, // Always-open
333
+ contentRating: "E",
334
+ });
335
+ ```
336
+ 4. Replace `submitGuide()` with `submissionGateway.submitIntent()`
337
+ 5. Replace `takePendingGuides()` with window closure and arbitration
338
+ 6. Integrate moderation gateway with existing text model moderation
339
+ 7. Test with existing payment flow
340
+
341
+ **Phase 3:**
342
+ - Add voting windows for game-show modes
343
+ - Add host selection for curated events
344
+ - Add parallel branches for remixes
345
+ - Add billing variants (pay-if-selected, pay-to-publish)
346
+
347
+ ## Example: Always-Open Paid Prompt Queue
348
+
349
+ This example shows how to replace the current `guides.ts` with the Interaction Orchestrator:
25
350
 
26
351
  ```typescript
27
- import { Pool } from 'pg';
28
352
  import {
29
- InteractionRepository,
30
- InteractionOrchestrator,
31
- } from '@portalshq/capability-interaction-orchestrator';
32
-
33
- const pool = new Pool({ connectionString: process.env.DATABASE_URL });
34
- const repository = new InteractionRepository(pool);
35
- const interactions = new InteractionOrchestrator(repository);
36
-
37
- const window = await interactions.windows.createWindow({
38
- worldId: 'channel-123',
39
- branchId: 'canonical',
40
- baseCommitHash: 'current-commit',
41
- conflictKey: 'story',
42
- allowedInputKinds: ['custom_prompt'],
43
- arbitrationPolicy: 'fifo',
44
- billingPolicy: 'prepaid_credits',
45
- billingTerms: {
46
- tenantId: 'creator-tenant',
47
- entitlementKind: 'creator-tenant:story_guide',
48
- amountCredits: 1,
49
- amountCents: 100,
50
- currency: 'USD',
51
- // Set sessionId only when the actual entitlement scope names a session.
52
- },
53
- maxSubmissionsPerUser: 1,
54
- maxSelectedInputs: 5,
55
- opensAt: new Date().toISOString(),
56
- closesAt: null,
57
- contentRating: 'E',
58
- });
59
- await interactions.windows.openWindow(window.id);
60
-
61
- const submission = await interactions.submissions.submitIntent({
62
- windowId: window.id,
63
- actorId: 'authenticated-consumer-id',
64
- idempotencyKey: 'request-id-kept-across-retries',
65
- kind: 'custom_prompt',
66
- payload: { prompt: 'Explore the old lighthouse' },
67
- });
68
- if (submission.status !== 'eligible') {
69
- // Rejected inputs and inputs awaiting human review do not enter selection.
70
- console.log(submission.status);
353
+ WindowManager,
354
+ SubmissionGateway,
355
+ ArbitrationEngine,
356
+ ModerationGateway,
357
+ BillingController,
358
+ EventPublisher,
359
+ type CreateWindowInput,
360
+ type SubmitIntentInput,
361
+ } from "@portalshq/capability-interaction-orchestrator";
362
+ import { createHash, randomUUID } from "node:crypto";
363
+ import type pg from "pg";
364
+
365
+ // Initialize components (in Phase 2, these will be real implementations)
366
+ const windowManager = new WindowManager(pool);
367
+ const submissionGateway = new SubmissionGateway(pool);
368
+ const arbitrationEngine = new ArbitrationEngine();
369
+ const moderationGateway = new ModerationGateway();
370
+ const billingController = new BillingController(monetizationStore);
371
+ const eventPublisher = new EventPublisher(fanoutBus);
372
+
373
+ // Create always-open window for paid prompts
374
+ async function createPromptWindow(channelId: string): Promise<string> {
375
+ const windowId = randomUUID();
376
+ const window = await windowManager.createWindow({
377
+ worldId: channelId,
378
+ branchId: "canonical",
379
+ baseCommitHash: currentCommit,
380
+ conflictKey: channelId,
381
+ allowedInputKinds: ["custom_prompt"],
382
+ arbitrationPolicy: "fifo",
383
+ billingPolicy: "prepaid_credits",
384
+ maxSubmissionsPerUser: 1,
385
+ maxSelectedInputs: 5,
386
+ opensAt: new Date().toISOString(),
387
+ closesAt: null, // Always-open
388
+ contentRating: "E",
389
+ moderationPolicyVersion: "1.0",
390
+ rulesetVersion: "1.0",
391
+ });
392
+ return window.id;
393
+ }
394
+
395
+ // Submit a paid prompt
396
+ async function submitPaidPrompt(
397
+ channelId: string,
398
+ userId: string,
399
+ prompt: string,
400
+ ): Promise<Submission> {
401
+ const windowId = getOrCreateWindowId(channelId);
402
+
403
+ const submission = await submissionGateway.submitIntent({
404
+ windowId,
405
+ actorId: userId,
406
+ idempotencyKey: createHash(userId + prompt).digest('hex'),
407
+ kind: "custom_prompt",
408
+ payload: { prompt },
409
+ });
410
+
411
+ return submission;
412
+ }
413
+
414
+ // Take prompts for next generation (replaces takePendingGuides)
415
+ async function takePromptsForGeneration(
416
+ channelId: string,
417
+ limit: number = 5,
418
+ ): Promise<Submission[]> {
419
+ const windowId = getWindowId(channelId);
420
+ const submissions = await submissionGateway.listSubmissions(windowId, "eligible");
421
+
422
+ // Resolve via FIFO arbitration
423
+ const resolution = await arbitrationEngine.resolve(
424
+ await windowManager.getWindow(windowId),
425
+ submissions.slice(0, limit),
426
+ );
427
+
428
+ // Get selected submissions
429
+ const selected = await Promise.all(
430
+ resolution.selectedSubmissionIds.map(id => submissionGateway.getSubmission(id))
431
+ );
432
+
433
+ return selected.filter((s): s is Submission => s !== null);
71
434
  }
72
435
  ```
73
436
 
74
- `actorId` must come from authenticated application identity. Authorize the caller
75
- against the window's world before exposing submission, cancellation, review,
76
- host-selection, refund or event methods over a network. This package is an internal
77
- module; it does not authenticate HTTP requests. Window configuration, billing terms,
78
- policy configuration and moderation context come from trusted application code.
79
- Submission payloads and app metadata cannot set prices, rank or host authority.
437
+ ## Architecture Diagram
80
438
 
81
- The server reloads prices for `billing.quote(window, actorId)`; the quote is an
82
- informational display, not a payment approval. All paid policies settle **credits
83
- already granted by monetization**. Fiat checkout, Stripe authorization and monetary
84
- refunds belong to monetization's purchase lifecycle. No synthetic Stripe holds are
85
- created here.
439
+ ```
440
+ ┌─────────────────────────────────────────────────────────────┐
441
+ │ Interaction Orchestrator Package │
442
+ │ (In-Process Only) │
443
+ ├─────────────────────────────────────────────────────────────┤
444
+ │ │
445
+ │ ┌──────────────────┐ ┌──────────────────┐ │
446
+ │ │ Window Manager │ │Submission Gateway│ │
447
+ │ │ - Lifecycle │ │ - Validation │ │
448
+ │ │ - Triggers │ │ - Idempotency │ │
449
+ │ │ - State Machine │ │ - Admission │ │
450
+ │ │ - setInterval │ │ │ │
451
+ │ └────────┬─────────┘ └────────┬─────────┘ │
452
+ │ │ │ │
453
+ │ └────────────┬───────────────┘ │
454
+ │ │ │
455
+ │ ┌────────────────────▼────────────────────┐ │
456
+ │ │ Moderation Gateway │ │
457
+ │ │ - Admission validation (built-in) │ │
458
+ │ │ - Content safety (rating-based) │ │
459
+ │ │ - Custom validators (pluggable) │ │
460
+ │ │ - E/T/T+/M rating system │ │
461
+ │ └────────────────────┬────────────────────┘ │
462
+ │ │ │
463
+ │ ┌────────────────────▼────────────────────┐ │
464
+ │ │ Arbitration Engine │ │
465
+ │ │ - FIFO │ │
466
+ │ │ - Plurality voting │ │
467
+ │ │ - First accepted │ │
468
+ │ │ - Ranked selection │ │
469
+ │ │ - Batch composition │ │
470
+ │ │ - Parallel branches │ │
471
+ │ │ - Host selection │ │
472
+ │ │ - Configurable per window │ │
473
+ │ └────────────────────┬────────────────────┘ │
474
+ │ │ │
475
+ │ ┌────────────────────▼────────────────────┐ │
476
+ │ │ Billing Controller │ │
477
+ │ │ - Quote generation │ │
478
+ │ │ - Credit reservation │ │
479
+ │ │ - Authorization/capture │ │
480
+ │ │ - Settlement │ │
481
+ │ │ - Refund handling │ │
482
+ │ │ - Integration with @portalshq/monetization│
483
+ │ └────────────────────┬────────────────────┘ │
484
+ │ │ │
485
+ │ ┌────────────────────▼────────────────────┐ │
486
+ │ │ Event Publisher │ │
487
+ │ │ - SSE events │ │
488
+ │ │ - Outbox pattern │ │
489
+ │ │ - Replay support │ │
490
+ │ │ - Integration with fanout-postgres │ │
491
+ │ └─────────────────────────────────────────┘ │
492
+ │ │
493
+ └─────────────────────────────────────────────────────────────┘
494
+ ```
495
+
496
+ ## Data Flow Diagram
497
+
498
+ ```
499
+ User Input
500
+ │
501
+ ▼
502
+ ┌─────────────┐
503
+ │ HTTP POST │
504
+ └──────┬──────┘
505
+ │
506
+ ▼
507
+ ┌──────────────────┐
508
+ │ Submission │
509
+ │ Gateway │
510
+ │ - Validate │
511
+ │ - Idempotency │
512
+ │ - Check window │
513
+ └──────┬───────────┘
514
+ │
515
+ ▼
516
+ ┌──────────────────┐
517
+ │ Moderation │
518
+ │ Gateway │
519
+ │ - Admission │
520
+ │ - Content safety │
521
+ │ - World rules │
522
+ └──────┬───────────┘
523
+ │
524
+ ▼
525
+ ┌──────────────────┐
526
+ │ Billing │
527
+ │ Controller │
528
+ │ - Quote │
529
+ │ - Authorize │
530
+ └──────┬───────────┘
531
+ │
532
+ ▼
533
+ ┌──────────────────┐
534
+ │ Submission │
535
+ │ Stored (DB) │
536
+ └──────────────────┘
537
+ │
538
+ ▼
539
+ ┌──────────────────┐
540
+ │ Window Close │
541
+ │ (setInterval) │
542
+ └──────┬───────────┘
543
+ │
544
+ ▼
545
+ ┌──────────────────┐
546
+ │ Arbitration │
547
+ │ Engine │
548
+ │ - Select inputs │
549
+ │ - Apply policy │
550
+ └──────┬───────────┘
551
+ │
552
+ ▼
553
+ ┌──────────────────┐
554
+ │ Resolution │
555
+ │ Created (DB) │
556
+ └──────┬───────────┘
557
+ │
558
+ ▼
559
+ ┌──────────────────┐
560
+ │ Billing Capture │
561
+ │ (if selected) │
562
+ └──────┬───────────┘
563
+ │
564
+ ▼
565
+ ┌──────────────────┐
566
+ │ Model Execution │
567
+ │ (App-owned) │
568
+ └──────┬───────────┘
569
+ │
570
+ ▼
571
+ ┌──────────────────┐
572
+ │ Event Publisher │
573
+ │ - Outbox │
574
+ │ - SSE │
575
+ └──────────────────┘
576
+ ```
577
+
578
+ ## Error Handling
579
+
580
+ All components should implement comprehensive error handling:
86
581
 
87
- Input JSON is limited to 64 KiB and 32 nesting levels; timestamps use ISO UTC with
88
- `Z`. Prompts/messages require their text field; votes require an option or target ID
89
- (and both must agree when present); mutations require target IDs and a parameter
90
- object; object creation requires `payload.object`; branch creation requires a name.
91
- Configure `allowedInputKinds` to operations implemented by your execution adapter.
582
+ - **Window Manager:** Handle invalid state transitions, missing windows
583
+ - **Submission Gateway:** Handle idempotency conflicts, closed windows, validation failures
584
+ - **Arbitration Engine:** Handle empty submissions, policy errors
585
+ - **Moderation Gateway:** Handle moderation service failures, rating violations
586
+ - **Billing Controller:** Handle authorization failures, payment errors
587
+ - **Event Publisher:** Handle fanout failures, retry with backoff
92
588
 
93
- ### Resolve, execute, commit
589
+ ## Logging
590
+
591
+ Components should use structured logging with context:
94
592
 
95
593
  ```typescript
96
- // For finite windows, closeWindow first. An always-open queue can resolve directly.
97
- const resolution = await interactions.resolveWindow(window.id, {
98
- idempotencyKey: 'generation-request-id',
99
- });
100
- const executable = await interactions.prepareExecution(resolution.id);
101
- // Pass executable to your execution adapter to generate a private preview/artifact.
102
- // Keep proposed world changes private until output moderation and commit succeed.
103
- // Then call commitResolution(resolution.id, proposedOutput, resultingCommitHash).
594
+ console.log(`[interaction-orchestrator] window=${windowId} state=${state} action=${action}`);
595
+ console.error(`[interaction-orchestrator] window=${windowId} error=${error.message}`);
104
596
  ```
105
597
 
106
- `resolveWindow` persists selection and losing-input refunds atomically. Selection
107
- has a window-scoped retry key; reusing it with a different host selection fails.
108
- Only one selected/executing resolution may exist per window. `prepareExecution`
109
- checks backing grants and captures selection-dependent credits before execution.
110
- `commitResolution(id, output, hash)` moderates the complete output, captures deferred
111
- publication credits and records fulfillment and events together. A non-allow output
112
- fails the resolution and releases/refunds its credits. For parallel branches, supply
113
- `{ [selectedSubmissionId]: branchCommitHash }` instead of one hash; the canonical
114
- head stays unchanged. Output and commit targets must match on a retry.
115
-
116
- The owned execution adapter must enforce base-commit conflicts against its world
117
- store. A hash supplied to this module identifies a private proposed artifact;
118
- publication happens only after a committed resolution. PostgreSQL transactions do
119
- not make an external provider call or world-store write atomic.
120
-
121
- `failResolution(id, reason)` refunds selected work and records failure. Use it after
122
- an execution failure or expired/revoked hold; retrying a failed resolution requires
123
- a new submission. `cancelWindow(id)` cancels pending inputs and refunds their credits
124
- in one transaction, refusing to interrupt an active resolution.
125
-
126
- ## Selection policies
127
-
128
- | Policy | Behavior |
129
- | --- | --- |
130
- | `fifo` | Admission order, with ID as the final deterministic tie-break |
131
- | `first_accepted` | The first eligible input |
132
- | `plurality` | Distinct actors per option/target, descending vote count, then admission order; selects one representative per winning target |
133
- | `ranked` | Trusted `arbitrationPolicyConfig.priorities[submissionId]`, then admission order; ignores payload priority |
134
- | `batch` | Admission order, combining nonoverlapping `targetEntityIds` up to capacity |
135
- | `parallel_branches` | Admission order up to capacity, one stable branch destination per selected input |
136
- | `host_selection` | Explicit `selectedSubmissionIds` passed to `resolveWindow`; rejects unknown, duplicate or excessive IDs |
137
-
138
- All policies respect `maxSelectedInputs`. Custom policies must partition the eligible
139
- inputs exactly once into selected and rejected IDs. The arbitration engine is a
140
- pure selection module; its `resolutionSequence: 0` is an unpersisted proposal.
141
- The orchestrator allocates the actual monotonic sequence under the window lock.
142
-
143
- For always-open FIFO/parallel queues, overflow stays eligible for the next generation
144
- and is recorded in `appMetadata.deferredSubmissionIds`. Finite-window losers are
145
- marked `not_selected` and refunded. Prompt/message/vote inputs can follow a moving
146
- queue head; state-changing inputs with an old base must be cancelled and resubmitted
147
- after application revalidation. Active per-user quota counts pending, eligible,
148
- selected and executing inputs; completed/rejected/cancelled inputs free that quota.
149
- Scheduled windows can coexist, but opening two windows with the same world, branch
150
- and conflict key fails.
151
-
152
- ## Moderation
153
-
154
- `ModerationGateway` checks every nested string in JSON input/output. Its defaults
155
- provide configurable term rules, Unicode normalization, word edges, input length
156
- 200 and output length 32,000. E blocks mild/strong profanity, explicit terms and
157
- graphic violence terms; T permits mild profanity; T+ permits strong profanity and
158
- graphic violence; M permits these categories. Blocked/prohibited terms apply at
159
- every rating. These are text rules, not semantic or image/video classification.
160
- Install application/model validators for contextual content decisions and non-text
161
- media inspection. Version your configuration with `moderationPolicyVersion`.
162
-
163
- Custom validators cannot bypass the built-in rules. Missing `validateOutput` falls
164
- back to `validate` with the complete output in a synthetic submission. Validator
165
- exceptions and malformed decisions stop qualification. `review` and `transform`
166
- remain in `moderating`; only an explicit trusted
167
- `submissions.reviewSubmission(id, 'allow' | 'reject', reasons)` completes them.
168
- Transforms are never silently applied. `contextFor(window)` injects application
169
- world rules; each request uses its own rating context.
170
-
171
- ## Billing behavior
172
-
173
- | Policy | Admission | Selection/execution | Commit |
174
- | --- | --- | --- | --- |
175
- | `free` | Zero-credit reservation | Verify lifecycle | No charge |
176
- | `prepaid_credits` | Consume credits | Validate backing grants | Fulfill |
177
- | `pay_to_generate` | Consume credits | Validate backing grants | Fulfill |
178
- | `pay_for_guaranteed_slot` | Claim capacity and consume credits | Guaranteed FIFO/parallel selection | Fulfill |
179
- | `capture_if_selected` | Deduct a credit hold from spendable balance | Capture selected holds; release losers | Fulfill |
180
- | `pay_to_publish` | Deduct a credit hold | Generate a private preview | Capture only on allowed output |
181
-
182
- Guaranteed slots require FIFO or parallel selection and cannot overbook capacity.
183
- Reservations default to one hour; configure `reservationTtlMs` (1 second–24 hours).
184
- A hold expires no later than its earliest backing grant. Captured prepaid credits
185
- remain spent while queued. Cancellation, failed execution, non-allow output and
186
- finite-window loss release/refund exactly once, restoring only surviving unexpired
187
- original grants. A deleted/refunded purchase's grant is never recreated. Expired
188
- credits cannot be made spendable by an interaction refund. Deferred captures check
189
- expiry and revocation again at execution/publication.
190
-
191
- ## Events and workers
192
-
193
- `EventPublisher` accepts `createPostgresFanoutBus(pool)` or `InMemoryFanoutBus` through
194
- the fanout seam. Set up `fanout_messages` as documented by
195
- [`capability-realtime-fanout-postgres`](../capability-realtime-fanout-postgres).
196
- Lifecycle writes enqueue events; call `drainOutbox()` from a worker. Publishing to
197
- the transport is at least once, with expiring claims and token-checked acknowledgment.
198
- Failed deliveries stay durable for retry. Each event has a stable ID and a
199
- transactionally allocated world sequence. Topics use `interaction:${worldId}`.
200
-
201
- `subscribe(worldId, afterSequence?, { signal? })` returns an async iterator. With a
202
- cursor it replays all later events in bounded pages; without one it starts at the
203
- current head. Fanout messages wake the iterator; the database remains authoritative
204
- for ordering and deduplication. Polling recovers missed hints. Abort, iterator return
205
- and exceptions release subscriptions and timers, including a read that is waiting.
206
- Use `String(event.sequence)` for SSE `id`; validate and authorize `Last-Event-ID`.
207
- `getEvents` returns at most 100 events per page. The iterator handles pagination.
208
-
209
- Run these bounded jobs with error reporting and nonoverlapping invocations:
210
-
211
- - `windows.startWindowCloser(5000)` closes due finite windows; always-open queues remain open.
212
- - `billing.expireReservations()` releases expired, unselected holds in batches of 100.
213
- - `submissions.resumeSubmission(id)` recovers a durable `received` admission after an outage. Find these through application recovery jobs or `listSubmissions(windowId, 'received')`.
214
- - `events.drainOutbox()` delivers a bounded outbox batch; repeat while work remains.
215
-
216
- Open scheduled windows explicitly at their scheduled time. Finish the active
217
- always-open queue resolution before explicitly closing its intake. Stop/await the closer,
218
- abort/return all event iterators, finish in-flight worker jobs and then close the
219
- caller-owned fanout bus and pool during shutdown. A selected/executing expired hold
220
- requires `failResolution`; it is never silently reassigned. Use trusted operator
221
- `billing.refund` for completed work; active resolutions must fail first.
222
-
223
- Repository transactions serialize admission/selection per window, world event writes
224
- per world, and credit mutations in grant-ID order. Keep network calls outside these
225
- transactions. The exported repository and `*InTransaction` methods are low-level
226
- integration seams; use `InteractionOrchestrator` for lifecycle operations. Retain
227
- outbox history for the promised replay period and monitor unpublished age, pending
228
- qualification age, expiring holds and failed resolutions. This package does not
229
- silently prune audit/replay data.
230
-
231
- ## Validation and migration
232
-
233
- ```sh
234
- npm run build --workspace=packages/monetization
235
- npm run build --workspace=packages/capability-interaction-orchestrator
236
- npm run typecheck --workspace=packages/capability-interaction-orchestrator
237
- npm run test:coverage --workspace=packages/capability-interaction-orchestrator
238
- ```
598
+ ## Testing
599
+
600
+ Phase 2 will include comprehensive test coverage:
601
+
602
+ - Unit tests for each component
603
+ - Integration tests with monetization package
604
+ - Integration tests with fanout-postgres package
605
+ - Database migration tests
606
+ - End-to-end workflow tests
607
+
608
+ Target: 95% code coverage
609
+
610
+ ## License
611
+
612
+ MIT
239
613
 
240
- Tests use embedded PostgreSQL (PGlite) by default, applying real migrations. To verify
241
- multiple connections against a scratch PostgreSQL instance, set both
242
- `INTERACTION_TEST_MODE=non-production` and `INTERACTION_TEST_DATABASE_URL`. Each suite
243
- creates/drops only its randomly named schema. Coverage thresholds are 95% for lines,
244
- statements, functions and branches and are enforced in the PostgreSQL CI job.
614
+ ## License
245
615
 
246
- See the repository's
247
- [migration guide](https://github.com/portalshq/portals-cloud/blob/main/packages/capability-interaction-orchestrator/docs/bears-live-app-migration.md)
248
- and [advanced usage](https://github.com/portalshq/portals-cloud/blob/main/packages/capability-interaction-orchestrator/docs/advanced-usage.md).
616
+ MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@portalshq/capability-interaction-orchestrator",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Reusable interaction orchestration for Portals: input windows, submission gateway, arbitration engine, moderation gateway, billing controller, and event publisher",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -34,7 +34,7 @@
34
34
  "license": "MIT",
35
35
  "dependencies": {
36
36
  "pg": "^8.16.3",
37
- "@portalshq/monetization": "^0.2.1"
37
+ "@portalshq/monetization": "^0.2.2"
38
38
  },
39
39
  "devDependencies": {
40
40
  "@types/node": "^20.0.0",
@@ -12,7 +12,6 @@
12
12
  CREATE TABLE IF NOT EXISTS input_windows (
13
13
  id text PRIMARY KEY,
14
14
  revision integer NOT NULL DEFAULT 1,
15
-
16
15
  world_id text NOT NULL,
17
16
  branch_id text NOT NULL,
18
17
  base_commit_hash text NOT NULL,
@@ -114,12 +113,10 @@ CREATE INDEX IF NOT EXISTS resolutions_window_idx ON resolutions(window_id, stat
114
113
  CREATE TABLE IF NOT EXISTS moderation_decisions (
115
114
  id text PRIMARY KEY,
116
115
  submission_id text NOT NULL REFERENCES submissions(id) ON DELETE CASCADE,
117
-
118
116
  status text NOT NULL CHECK (status IN ('allow', 'reject', 'review', 'transform')),
119
117
  reasons jsonb,
120
118
  policy_version text NOT NULL,
121
119
  reviewed_by text NOT NULL CHECK (reviewed_by IN ('rules', 'model', 'human')),
122
-
123
120
  created_at timestamptz NOT NULL DEFAULT now()
124
121
  );
125
122
 
@@ -134,7 +131,6 @@ CREATE TABLE IF NOT EXISTS interaction_outbox (
134
131
  event_type text NOT NULL,
135
132
  sequence integer NOT NULL,
136
133
  payload jsonb NOT NULL,
137
-
138
134
  published_at timestamptz,
139
135
  created_at timestamptz NOT NULL DEFAULT now()
140
136
  );