fix(iios): thread subject on create, graceful open_thread error, isolated test DB

- 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>
This commit is contained in:
2026-07-06 19:39:19 +05:30
parent 1af10d0f4a
commit c4206f9809
5 changed files with 47 additions and 13 deletions
@@ -59,17 +59,18 @@ export class MessageService {
* for a group. Opening an existing thread is a GOVERNED join: only an existing member may
* re-open it (policy `iios.thread.join`) — new members enter via addParticipant.
*/
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string }): Promise<OpenThreadResult> {
async openThread(threadId: string | null, principal: MessagePrincipal, opts?: { membership?: string; creatorRole?: string; subject?: string }): Promise<OpenThreadResult> {
if (!threadId) {
await decideOrThrow(this.ports, { action: 'iios.thread.create', scope: principal });
const scope = await this.actors.resolveScope(principal);
const actor = await this.actors.resolveActor(scope.id, principal);
// `membership` + `creatorRole` are generic, app-supplied thread attributes — the kernel
// stores/echoes them but never branches on their chat meaning (that lives in policy + app).
// `membership`/`creatorRole`/`subject` are generic, app-supplied thread attributes — the
// kernel stores/echoes them but never branches on their chat meaning (that lives in policy + app).
const thread = await this.prisma.iiosThread.create({
data: {
scopeId: scope.id,
createdByActorId: actor.id,
subject: opts?.subject?.trim() || undefined,
metadata: opts?.membership ? ({ membership: opts.membership } as Prisma.InputJsonValue) : undefined,
},
});