feat(iios): generic interaction-annotation primitive (backs emoji reactions)

Adds IiosInteractionAnnotation — an actor attaches an OPAQUE (annotationType, value)
label to an interaction. The kernel stores + aggregates them but never interprets the
strings (the chat app writes type "reaction" / value = emoji); the same primitive backs
pins/saves/flags/tags later. No chat vocabulary in kernel code — verified by grep.

- MessageService.toggleAnnotation(): governed (participant-only via new OPA rule
  iios.interaction.annotate), idempotent toggle keyed on
  (scope, interaction, actor, type, value); history DTO carries aggregated
  { type, value, users[] } groups.
- Gateway: `annotate` event → broadcasts `annotation` (refreshed user list) to the room.
- DevOpaPort: annotate allowed only for thread members (real OPA can tighten later).
- Migration add_interaction_annotations (additive; dev data untouched).

Tests: 178 pass (+3: toggle/aggregate, coexisting values, non-member denied).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-07 00:20:47 +05:30
parent c4206f9809
commit f3c4ba72b5
6 changed files with 207 additions and 1 deletions
@@ -155,3 +155,51 @@ describe('Governed membership + replies (v1.1, policy-enforced)', () => {
expect(cross.parentInteractionId).toBeUndefined(); // parent not in this thread
});
});
describe('Interaction annotations (generic reactions primitive)', () => {
it('toggleAnnotation adds then removes (toggle) and aggregates users into history', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
await s.addParticipant(threadId, alice, 'bob');
await s.openThread(threadId, bob);
const msg = await s.send(threadId, alice, { content: 'ship it' }, 'k1');
const add = await s.toggleAnnotation(msg.id, bob, 'reaction', '👍');
expect(add.op).toBe('add');
expect(add.users).toEqual(['bob']);
const add2 = await s.toggleAnnotation(msg.id, alice, 'reaction', '👍'); // alice also 👍
expect(add2.op).toBe('add');
expect(add2.users).toEqual(['alice', 'bob']); // sorted, deterministic
const rem = await s.toggleAnnotation(msg.id, bob, 'reaction', '👍'); // bob toggles off
expect(rem.op).toBe('remove');
expect(rem.users).toEqual(['alice']);
const hist = await s.history(threadId);
const m = hist.find((x) => x.id === msg.id)!;
expect(m.annotations).toEqual([{ type: 'reaction', value: '👍', users: ['alice'] }]);
});
it('different values coexist for the same actor (👍 and 🎉 both stick)', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
const msg = await s.send(threadId, alice, { content: 'hi' }, 'k1');
await s.toggleAnnotation(msg.id, alice, 'reaction', '👍');
await s.toggleAnnotation(msg.id, alice, 'reaction', '🎉');
const m = (await s.history(threadId)).find((x) => x.id === msg.id)!;
expect(m.annotations).toEqual(
expect.arrayContaining([
{ type: 'reaction', value: '👍', users: ['alice'] },
{ type: 'reaction', value: '🎉', users: ['alice'] },
]),
);
});
it('a non-member cannot annotate (governed by policy)', async () => {
const s = gov();
const { threadId } = await s.openThread(null, alice, { membership: 'group', creatorRole: 'ADMIN' });
const msg = await s.send(threadId, alice, { content: 'secret' }, 'k1');
await expect(s.toggleAnnotation(msg.id, bob, 'reaction', '👍')).rejects.toBeInstanceOf(PolicyDeniedError);
});
});