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:
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user