The generic primitives channels need beyond dm/group, kept policy-governed and
scope-fenced (kernel never interprets 'channel'/'public'):
- discoverThreads(principal, filter): scope-wide browse (NOT membership-scoped)
matching an opaque metadata filter, each result flagged joined; governed by
iios.thread.discover (scope fence in the query). REST: GET /v1/threads/discover
- open self-join for PUBLIC channels: openThread now passes visibility into the
join decision; dev-OPA iios.thread.join allows membership=channel+visibility=
public (dm/group/private-channel stay invite-only)
- leaveThread + iios.thread.leave + REST DELETE /v1/threads/:id/me
- tests: public self-join vs private denied, discover joined-flags + group
excluded + leave; dev-opa join-public/discover/leave rules (29 green)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Applies the context-attestation verifier at the request boundary via a guard, so a
stolen/forged/replayed attestation is rejected before IIOS acts — while staying safe
to roll out.
- ContextAttestationGuard: if an X-Context-Attestation header is present, verify it
(its app_id must match the actor token's app_id) → 403 on any failure; if absent,
allow only when IIOS_REQUIRE_ATTESTATION != 1 (dev/zero-trust), else 403. The flag is
read per-request and defaults OFF, so existing callers keep working until AppShell/
be-crm start forwarding attestations.
- AttestationModule (@Global): provides the verifier + Prisma stores + guard, and
dev-seeds the crm-support-widget client so locally minted attestations verify.
- Guard applied to ThreadsController + SupportController.
- Tests (5 pass, no DB): not-required-allows, required-rejects, valid, forged, replayed.
Workload/mTLS (proof #2) stays a mesh concern. Next: SDK header passthrough + demote
be-crm IiosClient to forward an attestation, then flip the flag to prove end-to-end.
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>
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>
- 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>
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>