@things-factory/ai-assistant 10.1.39 → 10.1.42

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/client/components/assistant-chat.ts +30 -1
  2. package/client/utils/assistant-request-context.ts +32 -0
  3. package/dist-client/components/assistant-chat.d.ts +12 -0
  4. package/dist-client/components/assistant-chat.js +24 -1
  5. package/dist-client/components/assistant-chat.js.map +1 -1
  6. package/dist-client/tsconfig.tsbuildinfo +1 -1
  7. package/dist-client/utils/assistant-request-context.d.ts +23 -0
  8. package/dist-client/utils/assistant-request-context.js +25 -0
  9. package/dist-client/utils/assistant-request-context.js.map +1 -1
  10. package/dist-server/service/assistant-capability-resolver.js +1 -1
  11. package/dist-server/service/assistant-capability-resolver.js.map +1 -1
  12. package/dist-server/service/assistant-chat-resolver.js +1 -1
  13. package/dist-server/service/assistant-chat-resolver.js.map +1 -1
  14. package/dist-server/service/assistant-mode/assistant-mode-resolver.js +2 -2
  15. package/dist-server/service/assistant-mode/assistant-mode-resolver.js.map +1 -1
  16. package/dist-server/service/chat-session/chat-session.js +1 -1
  17. package/dist-server/service/chat-session/chat-session.js.map +1 -1
  18. package/dist-server/service/chat-session/session-anchor.d.ts +5 -1
  19. package/dist-server/service/chat-session/session-anchor.js +23 -1
  20. package/dist-server/service/chat-session/session-anchor.js.map +1 -1
  21. package/dist-server/service/chat-session-resolver.js +21 -21
  22. package/dist-server/service/chat-session-resolver.js.map +1 -1
  23. package/dist-server/tsconfig.tsbuildinfo +1 -1
  24. package/package.json +4 -4
  25. package/server/service/assistant-capability-resolver.ts +1 -1
  26. package/server/service/assistant-chat-resolver.ts +1 -1
  27. package/server/service/assistant-mode/assistant-mode-resolver.ts +2 -2
  28. package/server/service/chat-session/chat-session.ts +1 -1
  29. package/server/service/chat-session/session-anchor.ts +26 -1
  30. package/server/service/chat-session/session-visibility.test.ts +47 -0
  31. package/server/service/chat-session-resolver.ts +21 -21
  32. package/server/service/session-anchor.test.ts +55 -1
  33. package/test/assistant-request-context.test.ts +41 -1
  34. package/test/open-doors.test.ts +16 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@things-factory/ai-assistant",
3
- "version": "10.1.39",
3
+ "version": "10.1.42",
4
4
  "main": "dist-server/index.js",
5
5
  "things-factory": true,
6
6
  "author": "heartyoh",
@@ -30,9 +30,9 @@
30
30
  "@operato/i18n": "^10.0.0",
31
31
  "@operato/styles": "^10.0.0",
32
32
  "@things-factory/ai-client-base": "^10.1.39",
33
- "@things-factory/auth-base": "^10.1.39",
33
+ "@things-factory/auth-base": "^10.1.40",
34
34
  "@things-factory/env": "^10.1.20",
35
- "@things-factory/shell": "^10.1.39",
35
+ "@things-factory/shell": "^10.1.40",
36
36
  "dompurify": "^3.0.0",
37
37
  "graphql-scalars": "^1.22.0",
38
38
  "graphql-tag": "^2.12.6",
@@ -44,5 +44,5 @@
44
44
  "copyfiles": "^2.4.1",
45
45
  "rimraf": "^5.0.0"
46
46
  },
47
- "gitHead": "fe5257ad8995fe7e79275e287f36e95b5fd32d1c"
47
+ "gitHead": "982aa01403b979c4e348fd207a3fb4d1a7b5f521"
48
48
  }
@@ -52,7 +52,7 @@ export class AssistantCapabilityResolver {
52
52
  @Query(() => AiCapabilityView, {
53
53
  description: 'Whether this deployment has an AI configured, and which registrations are incomplete. Read this before offering to ask the assistant — do not find out by failing.'
54
54
  })
55
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
55
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
56
56
  async aiCapability(@Ctx() _context: any): Promise<AiCapabilityView> {
57
57
  const capability = aiCapability()
58
58
  const warnings = [...getToolRegistryWarnings(), ...getAssistantModeWarnings()].map(warning => ({
@@ -200,7 +200,7 @@ export class AssistantChatResolver {
200
200
  * 2026-09-10. No `legacy` alias: this operation is new, so no one was ever passing under an old
201
201
  * name, and declaring an alias would open a second name that nothing needs.
202
202
  */
203
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
203
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
204
204
  async assistantChat(@Arg('input') input: AssistantChatInput, @Ctx() context: any): Promise<AssistantChatOutput> {
205
205
  const client = resolveAIClient()
206
206
  if (!client) {
@@ -83,7 +83,7 @@ export class AssistantModeResolver {
83
83
  description:
84
84
  'Conversations that stand for this kind of subject, the default first. A screen showing one subject opens the head of this list and offers the rest; a single entry means there is nothing to choose.'
85
85
  })
86
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
86
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
87
87
  async assistantModes(@Arg('subject') subject: string, @Ctx() context: any): Promise<AssistantModeChoice[]> {
88
88
  const t = requestTranslator(context)
89
89
 
@@ -101,7 +101,7 @@ export class AssistantModeResolver {
101
101
  description: 'What this conversation offers on screen — `/` sentences, the `@` catalog and the notices, translated.'
102
102
  })
103
103
  /* Reading what a conversation offers is reading, and the same door the session list uses. */
104
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
104
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
105
105
  async assistantMode(@Arg('kind') kind: string, @Ctx() context: any): Promise<AssistantModeView> {
106
106
  return assistantModeView(kind, requestTranslator(context)) as AssistantModeView
107
107
  }
@@ -80,7 +80,7 @@ export class ChatSession {
80
80
  @Field({
81
81
  nullable: true,
82
82
  description:
83
- "What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'space' | 'instance' | 'domain' | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
83
+ "What this conversation is anchored to: 'board' | 'figure' (authoring a Figure) | 'application' | 'space' | 'instance' | 'domain' | 'subject' (one subject in a 360, '<axis>:<itemId>') | 'none'. Absent on legacy rows — treated as 'board' when boardId is present."
84
84
  })
85
85
  anchorType?: string
86
86
 
@@ -50,11 +50,15 @@
50
50
  * space 공간 운영 대화 — 사이트와 그 공간에서 도는 트윈들
51
51
  * instance 트윈 하나에 대한 대화
52
52
  * domain 테넌트 전체가 주제인 대화 — 공장 전체 문답처럼 더 좁게 가리킬 것이 없을 때
53
+ * subject one subject in a 360 — `<axis>:<itemId>`: the 360 frame's axis word (ADR-0067, the shared
54
+ * frame; no new words are made here) and that row's uuid. Anchoring to `domain` instead
55
+ * would put every subject's conversations in one bucket and lose the per-subject list and
56
+ * history boundary (ruled 2026-09-24).
53
57
  * none 앵커로 찾을 수 없는 대화. 「아직 아무것도 고르지 않았다」는 화면 상태다
54
58
  */
55
59
  // Figure-specific history requested explicitly: reuse the existing varchar
56
60
  // anchor columns, without writing Figure ids into the legacy boardId column.
57
- export const SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'none'] as const
61
+ export const SESSION_ANCHOR_TYPES = ['board', 'figure', 'application', 'space', 'instance', 'domain', 'subject', 'none'] as const
58
62
 
59
63
  export type SessionAnchorType = (typeof SESSION_ANCHOR_TYPES)[number]
60
64
 
@@ -76,6 +80,25 @@ function assertAnchorType(anchorType: string | undefined, where: string): Sessio
76
80
  return type
77
81
  }
78
82
 
83
+ /**
84
+ * A `subject` anchor id is `<axis>:<itemId>`, the item being that row's uuid (ruled 2026-09-24).
85
+ *
86
+ * Checked because the shape is the whole contract: a screen that passes what it shows — an EPC like
87
+ * `urn:epc:id:sgtin:…`, or an order's name — would store a pointer to nothing stable. A name changes
88
+ * and the conversation is orphaned; an EPC splits on its own colons. Refused here, on write and on
89
+ * read, rather than stored and never found.
90
+ */
91
+ const SUBJECT_ANCHOR_ID = /^[a-z][a-z0-9-]*:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
92
+
93
+ function assertSubjectAnchorId(type: SessionAnchorType, id: string | undefined, where: string): void {
94
+ if (type !== 'subject' || !id || SUBJECT_ANCHOR_ID.test(id)) return
95
+ throw new Error(
96
+ `[ai-assistant/session-anchor] ${where}: '${id}' is not a subject anchor. ` +
97
+ "A subject anchor is '<axis>:<itemId>' — the 360 frame's axis word and the row's uuid, " +
98
+ "for example 'items:6f1c…'. Resolve what the screen shows (an EPC, a name) to the row's uuid first."
99
+ )
100
+ }
101
+
79
102
  /** 앵커 → 저장할 컬럼 값. 보드 앵커는 boardId 에도 같은 값을 넣어 기존 조회가 그대로 동작하게 한다. */
80
103
  export function anchorColumns(
81
104
  anchorType: string | undefined,
@@ -91,6 +114,7 @@ export function anchorColumns(
91
114
  "Use 'none' for a conversation that is deliberately not addressable by anchor."
92
115
  )
93
116
  }
117
+ assertSubjectAnchorId(type, id, 'anchorColumns')
94
118
  return { anchorType: type, anchorId: id, boardId: type === 'board' ? id : undefined }
95
119
  }
96
120
 
@@ -112,6 +136,7 @@ export function anchorColumns(
112
136
  */
113
137
  export function anchorWhere(domainId: string, anchorType: string, anchorId: string, kind?: string): any {
114
138
  const type = assertAnchorType(anchorType, 'anchorWhere')
139
+ assertSubjectAnchorId(type, anchorId, 'anchorWhere')
115
140
  const where = type === 'board' ? { boardId: anchorId, domain: { id: domainId } } : { anchorType: type, anchorId, domain: { id: domainId } }
116
141
  return kind ? { ...where, kind } : where
117
142
  }
@@ -50,3 +50,50 @@ describe("'none' is not an anchor", () => {
50
50
  expect(initialVisibility({ anchorType: 'space' })).toBe('open')
51
51
  })
52
52
  })
53
+
54
+ /*
55
+ * Calling a door is not reading a conversation (ruled 2026-09-24).
56
+ *
57
+ * Every ai-assistant door now admits the domain owner (`test/open-doors.test.ts`), so the owner may
58
+ * open the dock anywhere in the domain. That says who may *call* — not whose private conversation they
59
+ * may read. The room rule is asked with two things only, the visibility and whether the caller sits in
60
+ * the room, and neither place that reads a room asks anything about ownership. If one ever did, a domain
61
+ * owner would read every private conversation in the domain, and nothing on the screen would say so.
62
+ */
63
+ describe('the domain owner still reads a private conversation only as a participant', () => {
64
+ const { readFileSync } = require('fs')
65
+ const { join } = require('path')
66
+ const source = (path: string) => readFileSync(join(__dirname, '..', path), 'utf8') as string
67
+
68
+ /** The body of `chatMessages` — the stored path. The stream's path is the room lookup and `mayHearRoom`. */
69
+ const storedPath = () => {
70
+ const resolver = source('chat-session-resolver.ts')
71
+ const start = resolver.indexOf('async chatMessages(')
72
+ expect(start).toBeGreaterThan(0)
73
+ const end = resolver.indexOf('\n @', start)
74
+ return resolver.slice(start, end > start ? end : undefined)
75
+ }
76
+ const streamPath = () => {
77
+ const broadcast = source('chat-message/chat-message-broadcast.ts')
78
+ return source('chat-message/chat-message-room.ts') + broadcast.slice(broadcast.indexOf('export interface ChatRoomLookup'))
79
+ }
80
+
81
+ const OWNERSHIP = /owner|superUser|checkPermission|hasPrivilege|privilege/i
82
+
83
+ it('★ the rule has no input for ownership — visibility and participation, nothing else', () => {
84
+ expect(mayReadSession.length).toBe(2)
85
+ expect(mayReadSession('private', false)).toBe(false)
86
+ })
87
+
88
+ it('★ the stored path asks the rule, and asks nothing about ownership', () => {
89
+ const body = storedPath()
90
+ expect(body).toMatch(/mayReadSession\(/)
91
+ expect(body).not.toMatch(OWNERSHIP)
92
+ })
93
+
94
+ it('★ the stream asks the same rule, and asks nothing about ownership', () => {
95
+ const body = streamPath()
96
+ expect(body).toMatch(/mayReadSession\(/)
97
+ expect(body).not.toMatch(OWNERSHIP)
98
+ })
99
+ })
@@ -100,7 +100,7 @@ class ChatSessionInboxEntry {
100
100
  @Resolver()
101
101
  export class ChatSessionResolver {
102
102
  @Query(() => ChatSession, { nullable: true, description: 'Get AI chat session by id.' })
103
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
103
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
104
104
  async chatSession(
105
105
  @Arg('id') id: string,
106
106
  @Ctx() context: ResolverContext
@@ -113,7 +113,7 @@ export class ChatSessionResolver {
113
113
  description:
114
114
  'List AI chat sessions anchored to a target (oldest first). Anchor decides the nature and the history boundary of the conversation: board = authoring, space = the site and the twins running there. Board anchor also matches legacy rows that only have boardId.'
115
115
  })
116
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
116
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
117
117
  async chatSessionsByAnchor(
118
118
  @Arg('anchorType') anchorType: string,
119
119
  @Arg('anchorId') anchorId: string,
@@ -135,7 +135,7 @@ export class ChatSessionResolver {
135
135
  description:
136
136
  "List sessions anchored to a target with this viewer's unread state, most recent activity first. Supports free-text search over name and last preview, a mine-only filter, hidden inclusion, and paging. Uses the denormalized last-activity timestamp compared against the viewer's read point — no per-session aggregation."
137
137
  })
138
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
138
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
139
139
  async chatSessionInbox(
140
140
  @Arg('anchorType') anchorType: string,
141
141
  @Arg('anchorId') anchorId: string,
@@ -164,7 +164,7 @@ export class ChatSessionResolver {
164
164
  description:
165
165
  'Total number of sessions matching the same filters as chatSessionInbox (before paging). Request it alongside the list so the UI can tell "end of page" from "end of data".'
166
166
  })
167
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
167
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
168
168
  async chatSessionInboxTotal(
169
169
  @Arg('anchorType') anchorType: string,
170
170
  @Arg('anchorId') anchorId: string,
@@ -199,7 +199,7 @@ export class ChatSessionResolver {
199
199
  description:
200
200
  "Number of sessions on this anchor that the caller has hidden and that have had no activity since. Meant to be requested alongside chatSessionInbox in the same operation so the filter can say how many are folded away."
201
201
  })
202
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
202
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
203
203
  async hiddenSessionCount(
204
204
  @Arg('anchorType') anchorType: string,
205
205
  @Arg('anchorId') anchorId: string,
@@ -270,7 +270,7 @@ export class ChatSessionResolver {
270
270
  description:
271
271
  'Delete a chat session — creator only, since a shared deliberation cannot be un-deleted from the UI. Participants who are not the creator can hide it from their own list instead (hideAISession). Soft delete; broadcast as meta activity so every list drops it.'
272
272
  })
273
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
273
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
274
274
  async deleteAISession(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
275
275
  const { domain, user } = context.state
276
276
  const repo = getRepository(ChatSession)
@@ -302,7 +302,7 @@ export class ChatSessionResolver {
302
302
  description:
303
303
  'Hide (or unhide) a session from the calling user\'s own list. Per-user and reversible — the session and other participants are unaffected. Activity newer than the hide makes it reappear; the inbox filter (showHidden) reveals hidden ones.'
304
304
  })
305
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
305
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
306
306
  async hideAISession(
307
307
  @Arg('sessionId') sessionId: string,
308
308
  @Ctx() context: ResolverContext,
@@ -357,7 +357,7 @@ export class ChatSessionResolver {
357
357
  description:
358
358
  "Open this conversation to everyone in the domain, or close it to its participants. Only the owner may change it. Opening exposes what is already written — the screen says so before asking. Door checks on assistant answers are unaffected (ADR-0073)."
359
359
  })
360
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
360
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
361
361
  async setSessionVisibility(
362
362
  @Arg('sessionId') sessionId: string,
363
363
  @Arg('visibility') visibility: string,
@@ -394,7 +394,7 @@ export class ChatSessionResolver {
394
394
  description:
395
395
  'Rename a chat session. Any participant may rename it — the name is the topic label of a shared deliberation and the change is reversible. Broadcast as meta activity so every participant sees it without raising unread badges.'
396
396
  })
397
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
397
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
398
398
  async renameAISession(
399
399
  @Arg('sessionId') sessionId: string,
400
400
  @Arg('name') name: string,
@@ -429,7 +429,7 @@ export class ChatSessionResolver {
429
429
  description:
430
430
  "Mark a session as read up to now for the current user. Idempotent. Distinct from presence (lastSeenAt) — a window can be open without being read."
431
431
  })
432
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
432
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
433
433
  async markSessionRead(
434
434
  @Arg('sessionId') sessionId: string,
435
435
  @Ctx() context: ResolverContext
@@ -449,7 +449,7 @@ export class ChatSessionResolver {
449
449
  description:
450
450
  'List users in the current domain for the `@` mention popup. Anyone with the chat privilege may search here; this is separate from the admin-only users().'
451
451
  })
452
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
452
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
453
453
  async domainUsersForMention(
454
454
  @Arg('query', { nullable: true, description: 'Substring to match against name/email (case-insensitive). Empty → all.' }) query: string | undefined,
455
455
  @Arg('limit', () => Int, { nullable: true, defaultValue: 50 }) limit: number,
@@ -468,7 +468,7 @@ export class ChatSessionResolver {
468
468
  @Query(() => [ChatSessionParticipant], {
469
469
  description: 'List participants of a ChatSession (members / owner).'
470
470
  })
471
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
471
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
472
472
  async chatSessionParticipants(
473
473
  @Arg('sessionId') sessionId: string,
474
474
  @Ctx() context: ResolverContext
@@ -488,7 +488,7 @@ export class ChatSessionResolver {
488
488
  @Mutation(() => Boolean, {
489
489
  description: 'Presence heartbeat — bump current user\'s lastSeenAt in the session (online indicator).'
490
490
  })
491
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
491
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
492
492
  async touchPresence(@Arg('sessionId') sessionId: string, @Ctx() context: ResolverContext): Promise<boolean> {
493
493
  const { domain, user } = context.state
494
494
  const repo = getRepository(ChatSessionParticipant)
@@ -501,7 +501,7 @@ export class ChatSessionResolver {
501
501
  }
502
502
 
503
503
  @Query(() => [ChatMessage], { description: 'List chat messages of a session, oldest first.' })
504
- @Directive('@privilege(category: "ai-assistant", privilege: "query")')
504
+ @Directive('@privilege(category: "ai-assistant", privilege: "query", domainOwnerGranted: true)')
505
505
  async chatMessages(
506
506
  @Arg('sessionId') sessionId: string,
507
507
  @Arg('limit', () => Int, { nullable: true, defaultValue: 100 }) limit: number,
@@ -547,7 +547,7 @@ export class ChatSessionResolver {
547
547
  description:
548
548
  'Start (or get existing) AI chat session anchored to a target. Idempotent — returns first existing or creates one. Use anchorType board for board authoring, space for a site (its co-located twins).'
549
549
  })
550
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
550
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
551
551
  async startAISession(
552
552
  @Arg('anchorType') anchorType: string,
553
553
  @Arg('anchorId') anchorId: string,
@@ -584,7 +584,7 @@ export class ChatSessionResolver {
584
584
  description:
585
585
  'Always create a new AI chat session anchored to a target (no idempotent reuse). For multi-session UX — opening another tab.'
586
586
  })
587
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
587
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
588
588
  async createAISession(
589
589
  @Arg('anchorType') anchorType: string,
590
590
  @Arg('anchorId') anchorId: string,
@@ -626,7 +626,7 @@ export class ChatSessionResolver {
626
626
  @Mutation(() => Boolean, {
627
627
  description: 'Rename a ChatSession (tab label).'
628
628
  })
629
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
629
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
630
630
  async renameChatSession(
631
631
  @Arg('sessionId') sessionId: string,
632
632
  @Arg('name') name: string,
@@ -645,7 +645,7 @@ export class ChatSessionResolver {
645
645
  description:
646
646
  "What the host did with a proposal the assistant made. The host calls this once per card it was asked to run; the server records it as a system row, so the panel shows it, a refreshed panel still shows it, and the model reads on its next turn that its proposal was refused instead of assuming it landed (ADR-0070)."
647
647
  })
648
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
648
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
649
649
  async reportProposalOutcome(
650
650
  @Arg('sessionId') sessionId: string,
651
651
  @Arg('proposalKey') proposalKey: string,
@@ -695,7 +695,7 @@ export class ChatSessionResolver {
695
695
  description:
696
696
  "Say something to the people in this session. The turn is stored as the speaker's, addressed to the room, and the assistant is not called — it reads the line as context the next time someone asks it something. Returns the stored message id, or null when there was nothing to say (ADR-0070)."
697
697
  })
698
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
698
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
699
699
  async sayToRoom(
700
700
  @Arg('sessionId') sessionId: string,
701
701
  @Arg('content') content: string,
@@ -763,7 +763,7 @@ export class ChatSessionResolver {
763
763
  description:
764
764
  'Append a system note to a session — context that did not come from a participant typing (e.g. a conversation carried over from another surface, an external state change). The AI sees it as part of the shared history.'
765
765
  })
766
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
766
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
767
767
  async appendSessionNote(
768
768
  @Arg('sessionId') sessionId: string,
769
769
  @Arg('content') content: string,
@@ -813,7 +813,7 @@ export class ChatSessionResolver {
813
813
  description:
814
814
  "Carry a conversation from another surface into this session as its own turns, preserving order. A person's turns are stored as theirs, addressed to the room. An assistant turn is stored as the assistant's only when it names its original (sourceSessionId, sourceMessageId) and the server can read it; otherwise it is kept as a quotation on a system row — roles are the server's to write (ADR-0070)."
815
815
  })
816
- @Directive('@privilege(category: "ai-assistant", privilege: "mutation")')
816
+ @Directive('@privilege(category: "ai-assistant", privilege: "mutation", domainOwnerGranted: true)')
817
817
  async appendSessionTurns(
818
818
  @Arg('sessionId') sessionId: string,
819
819
  @Arg('turns', () => GraphQLJSON) turns: { role?: string; content?: string }[],
@@ -12,6 +12,9 @@
12
12
  * ③ 공간·인스턴스 앵커는 boardId 를 오염시키지 않는다(보드 조회에 섞이면 이력 경계가 무너진다).
13
13
  */
14
14
  import { anchorColumns, anchorWhere, isSessionAnchorType, SESSION_ANCHOR_TYPES } from './chat-session/session-anchor'
15
+ import { initialVisibility } from './chat-session/session-visibility'
16
+
17
+ const SUBJECT_ID = 'items:6f1c2a90-3b4d-4e5f-8a6b-7c8d9e0f1a2b'
15
18
 
16
19
  describe('앵커 저장 값 (anchorColumns)', () => {
17
20
  it('board 앵커는 anchorId 와 boardId 에 같은 값을 쓴다 — 기존 조회 하위호환', () => {
@@ -134,7 +137,9 @@ describe('앵커 조회 조건 (anchorWhere)', () => {
134
137
 
135
138
  it('모든 조회에 domain 조건이 포함된다 — 빠지면 남의 테넌트 대화가 보인다', () => {
136
139
  for (const type of SESSION_ANCHOR_TYPES) {
137
- expect(anchorWhere('dom-9', type, 'X').domain).toEqual({ id: 'dom-9' })
140
+ /* A subject anchor has a shape of its own (below); every other type takes any id. */
141
+ const id = type === 'subject' ? SUBJECT_ID : 'X'
142
+ expect(anchorWhere('dom-9', type, id).domain).toEqual({ id: 'dom-9' })
138
143
  }
139
144
  })
140
145
 
@@ -142,3 +147,52 @@ describe('앵커 조회 조건 (anchorWhere)', () => {
142
147
  expect(anchorWhere('dom-1', 'space', 'busan').boardId).toBeUndefined()
143
148
  })
144
149
  })
150
+
151
+ /*
152
+ * The subject anchor — one subject in a 360 (ruled 2026-09-24).
153
+ *
154
+ * `<axis>:<itemId>`: the 360 frame's axis word and that row's uuid. Tested on both sides the ruling
155
+ * split: without an id it throws, and with one it is stored as given and finds its own list again.
156
+ */
157
+ describe('the subject anchor', () => {
158
+ it('★ without an id it throws — a pointer to nothing would never be found again', () => {
159
+ expect(() => anchorColumns('subject', '')).toThrow(/was given no id/)
160
+ expect(() => anchorColumns('subject', undefined)).toThrow(/was given no id/)
161
+ })
162
+
163
+ it('★ `<axis>:<itemId>` is stored as given, and the lookup for that subject finds it', () => {
164
+ const stored = anchorColumns('subject', SUBJECT_ID)
165
+ expect(stored).toEqual({ anchorType: 'subject', anchorId: SUBJECT_ID, boardId: undefined })
166
+
167
+ const where = anchorWhere('dom-1', 'subject', SUBJECT_ID)
168
+ expect(where).toEqual({ anchorType: stored.anchorType, anchorId: stored.anchorId, domain: { id: 'dom-1' } })
169
+ })
170
+
171
+ it("another subject, another axis or another domain is another list", () => {
172
+ const other = 'items:0a1b2c3d-4e5f-4a6b-8c7d-9e0f1a2b3c4d'
173
+ const sameRowOtherAxis = SUBJECT_ID.replace('items:', 'locations:')
174
+
175
+ expect(anchorWhere('dom-1', 'subject', other)).not.toEqual(anchorWhere('dom-1', 'subject', SUBJECT_ID))
176
+ expect(anchorWhere('dom-1', 'subject', sameRowOtherAxis)).not.toEqual(anchorWhere('dom-1', 'subject', SUBJECT_ID))
177
+ expect(anchorWhere('dom-2', 'subject', SUBJECT_ID)).not.toEqual(anchorWhere('dom-1', 'subject', SUBJECT_ID))
178
+ })
179
+
180
+ it.each([
181
+ ['an EPC, as the screen shows a pallet', 'items:urn:epc:id:sgtin:0614141.107346.2017'],
182
+ ['a name, as the screen shows an order', 'orders:SO-A887417'],
183
+ ['a uuid with no axis', '6f1c2a90-3b4d-4e5f-8a6b-7c8d9e0f1a2b'],
184
+ ['an axis with no item', 'items:']
185
+ ])('★ refuses %s — on write and on read', (_what, id) => {
186
+ expect(() => anchorColumns('subject', id)).toThrow(/is not a subject anchor/)
187
+ expect(() => anchorWhere('dom-1', 'subject', id)).toThrow(/is not a subject anchor/)
188
+ })
189
+
190
+ it('the shape rule is the subject anchor\'s alone — other anchors keep taking the ids they take', () => {
191
+ expect(anchorColumns('space', 'busan').anchorId).toBe('busan')
192
+ expect(anchorColumns('application', 'operato-figure').anchorId).toBe('operato-figure')
193
+ })
194
+
195
+ it('★ a subject conversation is born open — an anchored conversation is about a shared thing (ADR-0073 decision 2)', () => {
196
+ expect(initialVisibility({ anchorType: 'subject' })).toBe('open')
197
+ })
198
+ })
@@ -1,7 +1,7 @@
1
1
  import assert from 'node:assert/strict'
2
2
  import { readFileSync } from 'node:fs'
3
3
  import { join } from 'node:path'
4
- import { assistantAutoAskKey, assistantModelFingerprint, assistantRecentSessions } from '../client/utils/assistant-request-context.ts'
4
+ import { assistantAutoAskKey, assistantDraftDecision, assistantModelFingerprint, assistantRecentSessions } from '../client/utils/assistant-request-context.ts'
5
5
 
6
6
  test('model signature ignores object key order but detects nested edits and array order', () => {
7
7
  assert.equal(assistantModelFingerprint({ b: 2, a: { y: 3, x: 1 } }), assistantModelFingerprint({ a: { x: 1, y: 3 }, b: 2 }))
@@ -28,3 +28,43 @@ test('send captures current context before optimistic input and attaches request
28
28
  assert.match(send, /detail: \{ actions: out.actions, sessionId: out.sessionId, requestBase \}/)
29
29
  assert.match(send, /requestContext\.hostContext/)
30
30
  })
31
+
32
+ /*
33
+ * The draft — a sentence placed in the input for the person to edit and send (twin's "new twin → AI").
34
+ */
35
+ test('★ a draft fills an empty input once per request, and a re-render does not fill it again', () => {
36
+ const first = assistantDraftDecision({ draft: 'make a twin of line 3', draftRequestId: 'r1', typed: '' })
37
+ assert.deepEqual(first, { fill: true, consumed: assistantAutoAskKey('make a twin of line 3', 'r1') })
38
+
39
+ const again = assistantDraftDecision({ draft: 'make a twin of line 3', draftRequestId: 'r1', drafted: first.consumed, typed: '' })
40
+ assert.equal(again.fill, false)
41
+
42
+ const next = assistantDraftDecision({ draft: 'make a twin of line 3', draftRequestId: 'r2', drafted: first.consumed, typed: '' })
43
+ assert.equal(next.fill, true, 'the same sentence under a new request is a new draft')
44
+ })
45
+
46
+ test('★ a draft never overwrites what the person is typing — and does not surface later', () => {
47
+ const out = assistantDraftDecision({ draft: 'make a twin', draftRequestId: 'r1', typed: 'how many forklifts' })
48
+ assert.equal(out.fill, false)
49
+ assert.ok(out.consumed, 'skipped, and remembered as consumed')
50
+ })
51
+
52
+ test('★ when autoAsk comes for the same request, autoAsk wins', () => {
53
+ const out = assistantDraftDecision({ draft: 'edit me', draftRequestId: 'r1', typed: '', autoAsk: 'send me', autoAskRequestId: 'r1' })
54
+ assert.equal(out.fill, false)
55
+
56
+ const otherRequest = assistantDraftDecision({ draft: 'edit me', draftRequestId: 'r2', typed: '', autoAsk: 'send me', autoAskRequestId: 'r1' })
57
+ assert.equal(otherRequest.fill, true)
58
+ })
59
+
60
+ test('no draft, nothing to do', () => {
61
+ assert.deepEqual(assistantDraftDecision({ typed: '' }), { fill: false })
62
+ })
63
+
64
+ test('the component places the draft and never sends it', () => {
65
+ const chat = readFileSync(join(__dirname, '../client/components/assistant-chat.ts'), 'utf8')
66
+ const updated = chat.slice(chat.indexOf(' updated(changed'), chat.indexOf('if (changed.has(\'subject\'))'))
67
+ const draftPart = updated.slice(updated.indexOf('assistantDraftDecision('))
68
+ assert.match(draftPart, /this\.input = this\.draft!/)
69
+ assert.doesNotMatch(draftPart, /this\.send\(\)/)
70
+ })
@@ -108,6 +108,22 @@ describe('every open door has a written reason', () => {
108
108
  })
109
109
  })
110
110
 
111
+ describe('the domain owner may call every door (ruled 2026-09-24)', () => {
112
+ /*
113
+ * The conversation doors did not admit domain owners, and warehouse has no role that grants
114
+ * ai-assistant — so on :3500 nobody could open the AI dock, the owner included. Every business door
115
+ * of the apps admits the owner; these now do too. Calling a door is not reading a conversation:
116
+ * private ones stay with their participants (`session-visibility.test.ts`).
117
+ */
118
+ it.each(['Query', 'Mutation'] as const)('%s — every declared door admits the owner', typeName => {
119
+ const notForOwners = fieldsOf(typeName).filter(field => {
120
+ const gate: any = privilegeOfField(typeName, field)
121
+ return gate.known && gate.gate && gate.gate.owner !== true
122
+ })
123
+ expect(notForOwners).toEqual([])
124
+ })
125
+ })
126
+
111
127
  describe('what the walk itself has to be true for', () => {
112
128
  it('reaches the whole schema, not a handful of fields', () => {
113
129
  /* If the resolver list shrank to nothing, the lists above would pass by having nothing to look at. */