Replaced the per-thread findFirst inside Promise.all (N parallel queries) with a
single Postgres DISTINCT ON query. A caller with many threads no longer fans out
and exhausts the connection pool (the conversation.list 500 under accumulated data).
message.spec 16/16 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generic kernel primitives so app backends (e.g. a CRM support glue) can link
their domain to interactions WITHOUT the kernel learning any app vocabulary:
- thread: accept an opaque `metadata` bag on create (merged with membership),
echo it in listThreads, and filter listThreads by `?metadata[key]=value`
(equality on the opaque bag — the kernel never interprets the keys).
- support: accept opaque `metadata` on createTicket/escalate (thread bag
inherited); add SupportService.assignTo — a policy-gated manual assignment
of a ticket to a target actor (POST /v1/support/tickets/:id/assignee).
- SDK @insignia/iios-kernel-client 0.1.2: createThread(metadata),
listThreads({metadata}) filter, createTicket(metadata), escalate(metadata),
assignTicket; ThreadSummary/Ticket expose the opaque bag.
Generic-safety gate holds: no crm/customer/lead in kernel code (opaque values
only). Tests: +2 (metadata create/filter, ticket metadata + assignTo); 31 pass.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Generic notification engine: reacts to message.sent and runs three gates before
delivering — policy (DM always; group only on @mention or reply-to-you), presence
(skip if the recipient is focused on that thread), mute (per-thread). Delivery via a
swappable NotificationPort (Web Push/VAPID adapter); a 'gone' (404/410) prunes the sub.
- PresenceService + gateway `focus_thread` signal + disconnect cleanup (room membership
!= viewing, since the sidebar joins every thread room).
- IiosNotificationSubscription table + `muted` on IiosThreadParticipant (migration).
- Endpoints: GET vapid-public-key, POST/DELETE subscribe, POST threads/:id/mute|unmute;
listThreads returns the caller's `muted`.
- DM-vs-group read from the opaque `membership` attribute in the notification POLICY only
— kernel stays generic (grep-verified).
Tests: 13 new (3 gates + reply-to-you + prune + web-push adapter states); full suite 205
green. Verified live: module boots, subscribe stored, mute/unmute round-trips.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Media the industry way: the DB stores a reference, bytes live behind a storage port.
- StoragePort + LocalDiskStorage (dev). MediaService presigns short-lived signed
upload/download URLs (HS256 tokens) pointing at IIOS's own endpoints; the bytes
never touch the kernel. Prod swaps STORAGE_PORT to S3/Supabase — same as auth.
- MediaController: presign-upload / PUT upload/:token (raw stream) / GET blob/:token /
presign-download. Uploads are OPA-governed (iios.media.upload: 25 MB cap +
image/video/audio/pdf/office allowlist); downloads are tenant-fenced by object key.
- send() + MessageDto carry an attachment (contentRef/mimeType/sizeBytes → generic
MEDIA_REF/VOICE_REF/FILE_REF part; DTO exposes kind image|video|audio|file).
Tests: media.service.spec (6) — round-trip, oversize/type denied, oversized PUT
refused, tampered token rejected, tenant fence. Full suite 192 green. Verified live:
presign→upload→send→history→signed download round-trips the exact bytes.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The app needs a reliable "is this message mine?" signal. senderActorId worked only
with a lazily-learned actorId, which Supabase token-refresh wipes. senderId is the
sender's externalId (email/username) — always present and comparable to the session
user — so message alignment is deterministic. Verified: senderId round-trips = email.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SessionVerifier gains a Supabase mode (set SUPABASE_URL): user SATs are ES256,
verified against the project's public JWKS — no shared secret. Keys are fetched at
startup (onModuleInit) and cached as PEM so verify() stays synchronous; a rotated
kid triggers a background refresh. Claims map to MessagePrincipal with userId=email
(stable, human-readable → mentions/directory keep working; RealMDM canonicalises
later). The legacy HS256 app-token path is unchanged (dev/tests untouched).
senderName now prefers the actor display name over the raw handle, so real names
show on bubbles when identity is an email.
Verified end-to-end against a live project: signup → real ES256 SAT → GET /v1/threads
200 → thread created + owned by the resolved email principal. Full suite 182 green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Mentions ride the existing event-driven inbox — no chat parsing in the kernel:
- send() carries an OPAQUE mentions[] (userIds) into the message event; the kernel
never parses "@". The app supplies the notify-list.
- InboxProjector fans out a MENTION inbox item (new generic inbox kind) to each
mentioned *participant* (never the sender), idempotent per source message; reading
the thread resolves the reader's MENTION + NEEDS_REPLY items to DONE.
- New MENTION value in the generic IiosInboxItemKind taxonomy (migration).
Pins/saves reuse the annotation primitive; new generic query powers a Saved list:
- MessageService.listMyAnnotated(principal, type) + GET /v1/threads/my-annotations
returns the caller's annotated messages (type "save") with thread context.
Tests: 182 pass (+4: mention fan-out, resolve-on-read, saved query, opaque mentions).
Verified live: mention→inbox delivery, read→resolve, pin/save persistence.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
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>
- openThread/createThread accept a generic `subject` (group name) — stored on the
thread, echoed via listThreads; kernel never interprets it.
- Gateway open_thread now returns an ACK'd { error } on failure instead of throwing
(which never acked → clients hung on "loading"). Non-member/missing-thread opens
fail cleanly.
- Tests run against an isolated `iios_test` database (vitest globalSetup creates +
migrates it; test.env overrides DATABASE_URL) so `pnpm test` can never TRUNCATE the
dev database again. Verified: full suite green, dev DB row counts unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
- Messages now carry senderName (resolved actorId → sourceHandle.externalId) so
clients render usernames instead of raw actor cuids.
- GET /v1/dev/users exposes the dev directory (DEV_USERS keys) so a host app can
validate add-by-username; real apps validate against their user store / MDM.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
GET /v1/threads now returns participants[] (member usernames) so a host app can
render conversation titles / member lists without a second call. Still generic —
it only lists members of threads the caller belongs to.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Enriches the platform PLANE, not the kernel:
- DevOpaPort: the dev OPA stub now evaluates a small policy table (behind the
same opa.decide) — DM capped at 2, add-participant requires member/group-admin,
governed self-join for membership threads. Real OPA swaps in unchanged.
- Dev IdP login (POST /v1/dev/login) issues the same JWT claims a real IdP would.
- PolicyDeniedFilter maps fail-closed denials to HTTP 403.
Generic kernel additions (no chat vocabulary — 'dm'/'group' live only as OPA
policy + an opaque thread attribute):
- MessageService.addParticipant (governed membership by userId), governed
openThread self-join (scoped to threads with a membership attribute),
parentInteractionId on send (reply link), and a generic listThreads.
- REST: GET /v1/threads, POST /v1/threads, POST /v1/threads/:id/participants;
socket add_participant + membership/parentInteractionId. ensureParticipant
gains a role.
Tests: dev-opa.port.spec + message.spec (DM cap / group admin / governed join /
listThreads / reply). smoke-membership.mjs; realtime smokes updated for governed
join. 175 unit tests + all smokes green; kernel free of dm/group literals.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Task T3.2: AiJobService.getArtifact/accept/reject assertOwns on artifact.job.scopeId;
listArtifacts scoped to caller. CalendarService.getMeeting/setConsent/generateTranscript/
summarize/listActionItems assertOwns on meeting.scopeId (optional principal → asserts
when supplied by a controller, skipped for internal calls). MessageService.getMessageById
gains the same optional fence. Controllers thread the principal through. All 46
ai/calendar/routing/messaging tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Event-driven projector creates/collapses NEEDS_REPLY items from message.sent
(traceId propagated; inbox.item.created emitted) and resolves them to DONE on
message.read; idempotent via processed-event ledger. markRead now emits a
message.read outbox event (atomic). OutboxRelay runs on a 500ms timer in the
app (off in tests). Shared resetDb (TRUNCATE CASCADE) fixes cross-spec FK
isolation. 32 tests green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>