@things-factory/ai-client-base 10.1.28 → 10.1.31

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 (27) hide show
  1. package/dist-server/service/agentic-loop/agentic-loop.js +13 -1
  2. package/dist-server/service/agentic-loop/agentic-loop.js.map +1 -1
  3. package/dist-server/service/assistant/registry-assistant.d.ts +11 -0
  4. package/dist-server/service/assistant/registry-assistant.js +5 -1
  5. package/dist-server/service/assistant/registry-assistant.js.map +1 -1
  6. package/dist-server/service/assistant/tool-scope.d.ts +3 -3
  7. package/dist-server/service/assistant/tool-scope.js +32 -15
  8. package/dist-server/service/assistant/tool-scope.js.map +1 -1
  9. package/dist-server/service/tool-registry/index.d.ts +1 -1
  10. package/dist-server/service/tool-registry/index.js.map +1 -1
  11. package/dist-server/service/tool-registry/registry.d.ts +9 -8
  12. package/dist-server/service/tool-registry/registry.js +26 -38
  13. package/dist-server/service/tool-registry/registry.js.map +1 -1
  14. package/dist-server/service/tool-registry/types.d.ts +16 -31
  15. package/dist-server/service/tool-registry/types.js.map +1 -1
  16. package/dist-server/tsconfig.tsbuildinfo +1 -1
  17. package/package.json +2 -2
  18. package/server/service/agentic-loop/agentic-loop.ts +14 -1
  19. package/server/service/agentic-loop/proposal-key.test.ts +31 -0
  20. package/server/service/assistant/registry-assistant.test.ts +65 -15
  21. package/server/service/assistant/registry-assistant.ts +13 -1
  22. package/server/service/assistant/tool-scope.test.ts +123 -0
  23. package/server/service/assistant/tool-scope.ts +34 -17
  24. package/server/service/tool-registry/index.ts +0 -1
  25. package/server/service/tool-registry/registry.test.ts +34 -66
  26. package/server/service/tool-registry/registry.ts +35 -46
  27. package/server/service/tool-registry/types.ts +16 -33
@@ -51,6 +51,7 @@ beforeEach(() => {
51
51
  kind: 'read',
52
52
  name: 'listFigures',
53
53
  description: 'lists figures',
54
+ doors: ['Query.figures'],
54
55
  schema: { type: 'object', properties: {} },
55
56
  builder: (args: any, context: any) => {
56
57
  ran.push({ name: 'listFigures', args, context })
@@ -61,7 +62,7 @@ beforeEach(() => {
61
62
  kind: 'write',
62
63
  name: 'addComponent',
63
64
  description: 'adds a component to the document',
64
- privilege: { category: 'board', privilege: 'mutation' },
65
+ doors: ['Mutation.updateBoard'],
65
66
  schema: { type: 'object', properties: {} },
66
67
  builder: () => {
67
68
  ran.push({ name: 'addComponent', args: {}, context: null })
@@ -79,6 +80,7 @@ beforeEach(() => {
79
80
  kind: 'read',
80
81
  name: 'readOrderState',
81
82
  description: 'reads live order state',
83
+ doors: ['Query.jobOrders'],
82
84
  schema: { type: 'object', properties: {} },
83
85
  builder: (args: any, context: any) => {
84
86
  ran.push({ name: 'readOrderState', args, context })
@@ -89,9 +91,8 @@ beforeEach(() => {
89
91
  } as any)
90
92
 
91
93
  /*
92
- * A category whose tools change something outside the conversation, each behind the same
93
- * privilege its GraphQL door uses. This is the shape of board-import's `materializeImportSession`,
94
- * which creates a Board.
94
+ * A category whose tools change something outside the conversation, each naming the GraphQL door
95
+ * it reaches. This is the shape of board-import's `materializeImportSession`, which creates a Board.
95
96
  */
96
97
  registerToolCategory({
97
98
  name: 'board-import',
@@ -101,7 +102,7 @@ beforeEach(() => {
101
102
  kind: 'read',
102
103
  name: 'getImportSession',
103
104
  description: 'reads an import session',
104
- privilege: { category: 'board-import', privilege: 'query' },
105
+ doors: ['Query.importSession'],
105
106
  schema: { type: 'object', properties: {} },
106
107
  builder: (args: any, context: any) => {
107
108
  ran.push({ name: 'getImportSession', args, context })
@@ -112,7 +113,7 @@ beforeEach(() => {
112
113
  kind: 'external',
113
114
  name: 'materializeImportSession',
114
115
  description: 'creates a board from an import session',
115
- privilege: { category: 'board-import', privilege: 'mutation' },
116
+ doors: ['Mutation.materializeImportSession'],
116
117
  schema: { type: 'object', properties: {} },
117
118
  builder: (args: any, context: any) => {
118
119
  ran.push({ name: 'materializeImportSession', args, context })
@@ -128,6 +129,12 @@ const input = (over: any = {}) => ({
128
129
  messages: [{ role: 'user' as const, content: 'what figures do we have' }],
129
130
  systemPrompt: 'you help someone draw a figure',
130
131
  state: { domain: { id: 'dom-1' }, user: { id: 'u-1' } },
132
+ /*
133
+ * Every tool here names a door, and a door is asked of the host (ADR-0071 decision 2). A caller
134
+ * who passes them all is the uninteresting case, so it is the default; the tests about the gate
135
+ * itself pass their own check, and one passes `toolAccess: undefined` on purpose.
136
+ */
137
+ toolAccess: async () => true,
131
138
  ...over
132
139
  })
133
140
 
@@ -246,7 +253,7 @@ describe('who may use a tool (A1)', () => {
246
253
  expect(ran).toHaveLength(0)
247
254
  })
248
255
 
249
- it('★ a tool that changes something outside is not offered here, even with the privilege', async () => {
256
+ it('★ a tool that changes something outside is not offered here, even when its door is open', async () => {
250
257
  const { client, seen } = recordingClient([okResult(undefined, [TC('materializeImportSession')]), okResult('done')])
251
258
 
252
259
  await registryAssistantChat(input({ client, toolCategories: ['board-import'], toolAccess: grantAll }))
@@ -255,7 +262,7 @@ describe('who may use a tool (A1)', () => {
255
262
  expect(ran.map(one => one.name)).not.toContain('materializeImportSession')
256
263
  })
257
264
 
258
- it('★ a tool behind a privilege the caller lacks is neither offered nor run when called by name', async () => {
265
+ it('★ a tool whose door the caller cannot pass is neither offered nor run when called by name', async () => {
259
266
  const { client, seen } = recordingClient([okResult(undefined, [TC('getImportSession')]), okResult('done')])
260
267
 
261
268
  await registryAssistantChat(input({ client, toolCategories: ['board-import'], toolAccess: denyAll }))
@@ -264,16 +271,16 @@ describe('who may use a tool (A1)', () => {
264
271
  expect(ran.map(one => one.name)).not.toContain('getImportSession')
265
272
  })
266
273
 
267
- it('★ with no access check at all, a tool behind a privilege stays closed', async () => {
274
+ it('★ with no access check at all, a tool that reaches a door stays closed', async () => {
268
275
  const { client, seen } = recordingClient([okResult(undefined, [TC('getImportSession')]), okResult('done')])
269
276
 
270
- await registryAssistantChat(input({ client, toolCategories: ['board-import'] }))
277
+ await registryAssistantChat(input({ client, toolCategories: ['board-import'], toolAccess: undefined }))
271
278
 
272
279
  expect(seen[0].tools.map((t: any) => t.name)).not.toContain('getImportSession')
273
280
  expect(ran.map(one => one.name)).not.toContain('getImportSession')
274
281
  })
275
282
 
276
- it('the access check is asked with the privilege the tool declares, and a grant opens the tool', async () => {
283
+ it('the access check is asked for the doors the tool names, and a caller holding them opens it', async () => {
277
284
  const asked: any[] = []
278
285
  const { client, seen } = recordingClient([okResult(undefined, [TC('getImportSession')]), okResult('done')])
279
286
 
@@ -281,14 +288,14 @@ describe('who may use a tool (A1)', () => {
281
288
  input({
282
289
  client,
283
290
  toolCategories: ['board-import'],
284
- toolAccess: async (privilege: any) => {
285
- asked.push(privilege)
286
- return privilege.privilege === 'query'
291
+ toolAccess: async (door: string) => {
292
+ asked.push(door)
293
+ return door.startsWith('Query.')
287
294
  }
288
295
  })
289
296
  )
290
297
 
291
- expect(asked).toContainEqual({ category: 'board-import', privilege: 'query' })
298
+ expect(asked).toContain('Query.importSession')
292
299
  expect(seen[0].tools.map((t: any) => t.name)).toContain('getImportSession')
293
300
  expect(ran.map(one => one.name)).toContain('getImportSession')
294
301
  })
@@ -307,3 +314,46 @@ describe('a client that cannot call tools', () => {
307
314
  await expect(registryAssistantChat(input({ client: {} as any }))).rejects.toThrow(/cannot call tools/)
308
315
  })
309
316
  })
317
+
318
+ /*
319
+ * 도구가 「어느 대화에서 불렸나」를 안다 — ADR-0061 결정 3.
320
+ *
321
+ * 이 칸은 A2(모드를 서버가 등록한다)가 서기 전까지 비어 있었고 주석만 「A2 가 서면」이라고 적혀 있었다.
322
+ * A2 가 선 뒤에도 채우는 쪽이 없어서 도구는 계속 `host` 만 봤다 — 그것은 클라이언트가 채우는 칸이다.
323
+ */
324
+ describe('the tool context carries the conversation', () => {
325
+ it('★ 부르는 쪽이 준 mode 가 도구에게 간다 — 서버의 값이다', async () => {
326
+ const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
327
+
328
+ await registryAssistantChat(
329
+ input({ client, toolCategories: ['figure-authoring'], mode: { kind: 'twin.space', target: 'SITE-1' } })
330
+ )
331
+
332
+ expect(ran[0].context.mode).toEqual({ kind: 'twin.space', target: 'SITE-1' })
333
+ })
334
+
335
+ it('★ 그 값은 `host` 와 섞이지 않는다 — 하나는 서버가, 하나는 클라이언트가 채운다', async () => {
336
+ const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
337
+
338
+ await registryAssistantChat(
339
+ input({
340
+ client,
341
+ toolCategories: ['figure-authoring'],
342
+ hostContext: { figureId: 'fig-1' },
343
+ mode: { kind: 'figure.modeller' }
344
+ })
345
+ )
346
+
347
+ expect(ran[0].context).toEqual(
348
+ toolCallContextFixture({ domainId: 'dom-1', userId: 'u-1', host: { figureId: 'fig-1' }, mode: { kind: 'figure.modeller' } })
349
+ )
350
+ })
351
+
352
+ it('mode 를 안 주면 그 칸은 없다 — 빈 값을 지어내지 않는다', async () => {
353
+ const { client } = recordingClient([okResult(undefined, [TC('listFigures')]), okResult('ok')])
354
+
355
+ await registryAssistantChat(input({ client, toolCategories: ['figure-authoring'] }))
356
+
357
+ expect(ran[0].context).not.toHaveProperty('mode')
358
+ })
359
+ })
@@ -49,6 +49,14 @@ export interface RegistryAssistantInput {
49
49
  toolAccess?: ToolAccessCheck
50
50
  /** Passed to tools as `context.host`. Client-supplied — never used for authorization. */
51
51
  hostContext?: any
52
+ /**
53
+ * Which conversation this is — `{ kind, target }` from the registered mode (ADR-0061 decision 3).
54
+ *
55
+ * Tools see it as `context.mode`. It is the server's value, resolved from the registry before the
56
+ * turn runs, so a tool that behaves differently in two conversations reads it here and not from
57
+ * `host`, which the client fills.
58
+ */
59
+ mode?: { kind: string; target?: string }
52
60
  /** Caller's koa state (domain, user, tx). Tools that read the database need it. */
53
61
  state: any
54
62
  model?: string
@@ -106,7 +114,11 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
106
114
  const guidance = toolScopeGuidance(scope)
107
115
  const prompt = guidance.length > 0 ? `${systemPrompt}\n\n${guidance.join('\n\n')}` : systemPrompt
108
116
 
109
- const toolContext: ToolCallContext = { state, host: hostContext ?? {} }
117
+ /*
118
+ * `mode` 는 서버의 값이다 — 등록된 모드에서 왔고, `host` 와 달리 클라이언트가 못 고친다. A2 가 서기
119
+ * 전에는 채울 것이 없어 비워 두었다(ADR-0061 결정 3 의 주석). 이제 부르는 쪽이 들고 있다.
120
+ */
121
+ const toolContext: ToolCallContext = { state, host: hostContext ?? {}, ...(input.mode ? { mode: input.mode } : {}) }
110
122
 
111
123
  const result = await assistantChat<ToolCallContext, unknown>({
112
124
  initialConversation: messages,
@@ -0,0 +1,123 @@
1
+ /**
2
+ * What this guards: which tools one conversation is offered, now that the **doors** decide it
3
+ * (ADR-0071 decision 2, added to 2026-09-18).
4
+ *
5
+ * The three states of `doors` are three different answers, and the interesting one is the third:
6
+ * a tool that says nothing is **left out**, not opened. Reading an absent declaration as "nothing
7
+ * to check" is how the board-import tool reached every conversation once already.
8
+ */
9
+ import { registerToolCategory, clearToolRegistry } from '../tool-registry/registry'
10
+ import type { ToolCategory, ToolSpec } from '../tool-registry/types'
11
+ import { resolveToolScope, narrowToolScope, EMPTY_TOOL_SCOPE } from './tool-scope'
12
+
13
+ afterEach(() => clearToolRegistry())
14
+
15
+ function category(name: string, specs: Partial<ToolSpec>[]): ToolCategory {
16
+ return {
17
+ name,
18
+ guidance: `- fixture guidance for ${name}`,
19
+ specs: specs.map(s => ({
20
+ kind: 'read' as const,
21
+ name: 'unnamed',
22
+ description: 'desc',
23
+ schema: {},
24
+ builder: () => ({}),
25
+ ...s
26
+ })) as ToolSpec[]
27
+ }
28
+ }
29
+
30
+ const names = (scope: { specs: ReadonlyArray<ToolSpec> }) => scope.specs.map(s => s.name)
31
+
32
+ /** A caller holding these doors and no others. */
33
+ const holding = (...doors: string[]) => jest.fn(async (door: string) => doors.includes(door))
34
+
35
+ describe('resolveToolScope — the doors decide', () => {
36
+ test('★ a tool that names no doors at all is left out, not let through', async () => {
37
+ registerToolCategory(category('mixed', [{ name: 'undeclared' }, { name: 'declared', doors: ['Query.a'] }]))
38
+
39
+ const scope = await resolveToolScope(['mixed'], holding('Query.a'))
40
+
41
+ expect(names(scope)).toEqual(['declared'])
42
+ })
43
+
44
+ test('★ `doors: []` passes without asking — it reaches nothing to ask about', async () => {
45
+ const access = holding()
46
+ registerToolCategory(category('surface', [{ name: 'getOpenScene', doors: [] }]))
47
+
48
+ expect(names(await resolveToolScope(['surface'], access))).toEqual(['getOpenScene'])
49
+ expect(access).not.toHaveBeenCalled()
50
+ })
51
+
52
+ test('★ several doors are AND — half of them is not enough', async () => {
53
+ registerToolCategory(
54
+ category('plant', [
55
+ { name: 'overview', doors: ['Query.jobOrders', 'Query.productionReports'] },
56
+ { name: 'findJobOrders', doors: ['Query.jobOrders'] }
57
+ ])
58
+ )
59
+
60
+ const scope = await resolveToolScope(['plant'], holding('Query.jobOrders'))
61
+
62
+ expect(names(scope)).toEqual(['findJobOrders'])
63
+ })
64
+
65
+ test('each door is asked once however many tools reach it', async () => {
66
+ const access = holding('Query.jobOrders', 'Query.productionReports')
67
+ registerToolCategory(
68
+ category('plant', [
69
+ { name: 'overview', doors: ['Query.jobOrders', 'Query.productionReports'] },
70
+ { name: 'findJobOrders', doors: ['Query.jobOrders'] },
71
+ { name: 'attention', doors: ['Query.jobOrders', 'Query.productionReports'] }
72
+ ])
73
+ )
74
+
75
+ await resolveToolScope(['plant'], access)
76
+
77
+ expect(access.mock.calls.map(c => c[0]).sort()).toEqual(['Query.jobOrders', 'Query.productionReports'])
78
+ })
79
+
80
+ test('★ no access check closes every tool that reaches a door', async () => {
81
+ registerToolCategory(category('plant', [{ name: 'findJobOrders', doors: ['Query.jobOrders'] }, { name: 'getOpenScene', doors: [] }]))
82
+
83
+ expect(names(await resolveToolScope(['plant']))).toEqual(['getOpenScene'])
84
+ })
85
+
86
+ test('a door check that throws closes that door', async () => {
87
+ const access = jest.fn(async () => {
88
+ throw new Error('auth is down')
89
+ })
90
+ registerToolCategory(category('plant', [{ name: 'findJobOrders', doors: ['Query.jobOrders'] }]))
91
+
92
+ expect(await resolveToolScope(['plant'], access)).toEqual(EMPTY_TOOL_SCOPE)
93
+ })
94
+
95
+ test('a category left with no usable tool drops out, so its guidance is not read to the model', async () => {
96
+ registerToolCategory(category('plant', [{ name: 'findJobOrders', doors: ['Query.jobOrders'] }]))
97
+ registerToolCategory(category('twin', [{ name: 'queryState', doors: ['Query.twinReplay'] }]))
98
+
99
+ const scope = await resolveToolScope(['plant', 'twin'], holding('Query.twinReplay'))
100
+
101
+ expect(scope.categories.map(c => c.name)).toEqual(['twin'])
102
+ })
103
+
104
+ test('nothing asked for is nothing offered', async () => {
105
+ registerToolCategory(category('plant', [{ name: 'findJobOrders', doors: [] }]))
106
+
107
+ expect(await resolveToolScope([], holding())).toEqual(EMPTY_TOOL_SCOPE)
108
+ expect(await resolveToolScope(undefined as any, holding())).toEqual(EMPTY_TOOL_SCOPE)
109
+ })
110
+
111
+ test('narrowing keeps the same reading — a surface that runs only reads', async () => {
112
+ registerToolCategory(
113
+ category('twin', [
114
+ { name: 'queryState', doors: ['Query.twinReplay'] },
115
+ { name: 'bindEntity', kind: 'write', doors: ['Query.twinReplay'] }
116
+ ])
117
+ )
118
+
119
+ const scope = await resolveToolScope(['twin'], holding('Query.twinReplay'))
120
+
121
+ expect(names(narrowToolScope(scope, spec => spec.kind === 'read'))).toEqual(['queryState'])
122
+ })
123
+ })
@@ -4,23 +4,32 @@
4
4
  *
5
5
  * ── Two questions, one list ───────────────────────────────────────────────────────
6
6
  * Which categories did this surface ask for? `toolCategories`
7
- * Which of their tools may this caller use? each spec's `privilege`, asked of `access`
7
+ * Which of their tools may this caller use? each spec's `doors`, asked of `access`
8
8
  *
9
9
  * **Nothing asked means nothing offered.** Omitting the category list used to mean "every
10
10
  * registered category"; that default is the one that opened board-import's Board-creating tool to
11
11
  * any conversation. An absent answer leans to the closed side (ADR-0047 ② · ADR-0053).
12
12
  *
13
- * **No access check means privileged tools stay closed.** A caller that cannot say who is asking
14
- * gets only the tools that need no privilege.
13
+ * **The doors are the gate** (ADR-0071 decision 2). A tool used to declare its own copy of what its
14
+ * door asks, and this asked that copy. Two declarations of one fact drift, and the looser one wins;
15
+ * plant's hand-written read check and its `doors` list were that pair. Now a tool names the doors it
16
+ * reaches and the host resolves each one against the door's own declaration, so what passes here is
17
+ * what passes at the door.
18
+ *
19
+ * **All the doors, not one of them.** A tool that answers from two doors hands back what is behind
20
+ * both, so the caller holds both (AND, the same reading as AUTH's `requiresAll`).
21
+ *
22
+ * **No access check means a tool that reaches a door stays closed.** A caller that cannot say who is
23
+ * asking gets only the tools that reach nothing.
15
24
  *
16
25
  * The permission check itself belongs to auth, which this package does not depend on, so the host
17
- * hands it in (`ai-assistant` builds it from the request state).
26
+ * hands it in (`ai-assistant` builds it from the request state and AUTH's field table).
18
27
  */
19
28
  import { getToolCategories, type ToolCategoryFilter } from '../tool-registry/index.js'
20
- import type { ToolPrivilege, ToolSpec } from '../tool-registry/types.js'
29
+ import type { ToolSpec } from '../tool-registry/types.js'
21
30
 
22
- /** Answers "may this caller use a door guarded by this privilege". */
23
- export type ToolAccessCheck = (privilege: ToolPrivilege) => Promise<boolean>
31
+ /** Answers "may this caller pass this door" — `'Query.jobOrders'`. */
32
+ export type ToolAccessCheck = (door: string) => Promise<boolean>
24
33
 
25
34
  export interface ScopedCategory {
26
35
  readonly name: string
@@ -38,20 +47,28 @@ export interface ToolScope {
38
47
 
39
48
  export const EMPTY_TOOL_SCOPE: ToolScope = Object.freeze({ categories: [], specs: [] })
40
49
 
41
- const privilegeKey = (p: ToolPrivilege) => [p.category, p.privilege, !!p.owner, !!p.super].join('|')
42
-
43
50
  export async function resolveToolScope(requested: ToolCategoryFilter, access?: ToolAccessCheck): Promise<ToolScope> {
44
51
  if (!requested || requested.length === 0) return EMPTY_TOOL_SCOPE
45
52
 
46
- /* One check per distinct privilege — a category of ten read tools asks once. */
53
+ /* One check per distinct door — five tools behind `Query.jobOrders` ask once. */
47
54
  const answers = new Map<string, Promise<boolean>>()
48
- const mayUse = (spec: ToolSpec): Promise<boolean> => {
49
- const privilege = spec.privilege
50
- if (!privilege) return Promise.resolve(true)
51
- if (!access) return Promise.resolve(false)
52
- const key = privilegeKey(privilege)
53
- if (!answers.has(key)) answers.set(key, access(privilege).catch(() => false))
54
- return answers.get(key)!
55
+ const passes = (door: string): Promise<boolean> => {
56
+ if (!answers.has(door)) answers.set(door, access!(door).catch(() => false))
57
+ return answers.get(door)!
58
+ }
59
+
60
+ const mayUse = async (spec: ToolSpec): Promise<boolean> => {
61
+ /*
62
+ * Said nothing. Not open — left out, and the boot report names it. An answer this tool built
63
+ * would be readable by no one (ADR-0073), so offering it would only produce dead answers.
64
+ */
65
+ if (!Array.isArray(spec.doors)) return false
66
+ /* Reaches no door: it hands back what the surface itself sent. There is nothing to gate. */
67
+ if (spec.doors.length === 0) return true
68
+ if (!access) return false
69
+
70
+ const held = await Promise.all(spec.doors.map(passes))
71
+ return held.every(Boolean)
55
72
  }
56
73
 
57
74
  const categories: ScopedCategory[] = []
@@ -23,7 +23,6 @@ export type { ToolCategoryFilter, ToolRegistryWarning } from './registry'
23
23
  export type {
24
24
  ToolSpec,
25
25
  ToolSpecBase,
26
- ToolPrivilege,
27
26
  ReadToolSpec,
28
27
  WriteToolSpec,
29
28
  ActionToolSpec,
@@ -34,8 +34,6 @@ function makeCategory(name: string, specs: Partial<ToolSpec>[]): ToolCategory {
34
34
  description: 'desc',
35
35
  schema: {},
36
36
  builder: () => ({}),
37
- /* A tool that changes something must name its door (A1). The fixture does, like guidance. */
38
- ...(s.kind && s.kind !== 'read' ? { privilege: { category: 'fixture', privilege: 'mutation' } } : {}),
39
37
  ...s
40
38
  })) as ToolSpec[]
41
39
  }
@@ -176,7 +174,7 @@ describe('registry — spec 보존', () => {
176
174
  kind: 'write',
177
175
  name: 't1',
178
176
  description: 'main desc',
179
- privilege: { category: 'fixture', privilege: 'mutation' },
177
+ doors: ['Mutation.t1'],
180
178
  themeNote: '⚠ note',
181
179
  schema: { type: 'object' },
182
180
  builder: () => null
@@ -262,89 +260,59 @@ describe('registry — 지침 없는 카테고리는 등록되지 않는다', ()
262
260
  })
263
261
  })
264
262
 
265
- describe('registry — a tool that changes something names its door (A1, stage one: surface)', () => {
266
- test.each(['write', 'action', 'external'] as const)(
267
- '★ a %s tool without a privilege registers, and the omission is recorded as a code',
268
- kind => {
269
- registerToolCategory({
270
- name: 'no-door',
271
- guidance: '- fixture guidance for no-door',
272
- specs: [{ kind, name: 'changesThings', description: 'd', schema: {}, builder: () => null } as any]
273
- })
274
- expect(getToolKind('changesThings')).toBe(kind)
275
- expect(getToolRegistryWarnings()).toEqual([
276
- expect.objectContaining({
277
- code: 'tool-privilege-undeclared',
278
- params: { category: 'no-door', tool: 'changesThings', kind }
279
- })
280
- ])
281
- }
282
- )
283
-
284
- test('a privilege with no privilege name is recorded the same way', () => {
263
+ describe('registry — a tool names the doors it reaches (ADR-0071 decision 2)', () => {
264
+ /*
265
+ * 문을 안 적은 도구는 그 답을 **아무도 못 읽게 만든다** — 쓴 사람 본인까지.
266
+ *
267
+ * ADR-0073 뒤로 답 행에 도구가 지난 문이 적히고, 선언이 없는 도구는 `unresolved:<도구>` 로 적혀
268
+ * 아무도 못 지난다. 2026-09-18 트윈 모델러에서 저작 대화의 답이 새로 고친 뒤 작성자에게
269
+ * 「이 답을 볼 권한이 없습니다」로 보였다. 2026-09-18 부터는 관문도 그 도구를 내보내지 않는다 —
270
+ * 이 목록이 「무엇이 빠져 있나」를 말하는 유일한 자리다.
271
+ */
272
+ test.each(['read', 'write', 'action', 'external'] as const)('★ a %s tool that declares no doors is reported by name', kind => {
285
273
  registerToolCategory({
286
- name: 'half-door',
287
- guidance: '- fixture guidance for half-door',
288
- specs: [
289
- { kind: 'external', name: 'halfDoor', description: 'd', schema: {}, builder: () => null, privilege: { category: 'board' } } as any
290
- ]
274
+ name: 'no-door',
275
+ guidance: '- fixture guidance for no-door',
276
+ specs: [{ kind, name: 'reachesSomething', description: 'd', schema: {}, builder: () => null } as any]
291
277
  })
292
- expect(getToolRegistryWarnings().map(w => w.params.tool)).toEqual(['halfDoor'])
293
- })
294
278
 
295
- test('a declared tool leaves no warning', () => {
296
- registerToolCategory(makeCategory('declared', [{ name: 'w1', kind: 'write' }]))
297
- expect(getToolRegistryWarnings()).toEqual([])
279
+ expect(getToolKind('reachesSomething')).toBe(kind)
280
+ expect(getToolRegistryWarnings()).toEqual([
281
+ expect.objectContaining({
282
+ code: 'tool-doors-undeclared',
283
+ params: { category: 'no-door', tool: 'reachesSomething', kind }
284
+ })
285
+ ])
298
286
  })
299
287
 
300
- /*
301
- * 읽는 도구도 자기가 닿는 문을 말한다(ADR-0071 결정 2). 그전까지 업무 권한을 스스로 확인하는 것은
302
- * plant 뿐이었고, twin · figure 의 읽는 도구는 테넌트만 봤다 — 대화 문 권한만 있으면 그 자료를 읽었다.
303
- */
304
- test('★ a read tool that says nothing about its door is recorded too', () => {
305
- registerToolCategory(makeCategory('reads', [{ name: 'r1', kind: 'read' }]))
288
+ test('★ `doors: []` is a declaration — it says this tool reaches none', () => {
289
+ registerToolCategory(makeCategory('surface', [{ name: 'getOpenScene', kind: 'read', doors: [] }]))
306
290
 
307
- expect(getToolRegistryWarnings()).toEqual([
308
- expect.objectContaining({ code: 'tool-privilege-undeclared', params: { category: 'reads', tool: 'r1', kind: 'read' } })
309
- ])
291
+ expect(getToolRegistryWarnings()).toEqual([])
310
292
  })
311
293
 
312
- test('★ `doors: []` is an answer — a tool that reaches no door has nothing to gate', () => {
313
- registerToolCategory({
314
- name: 'surface-only',
315
- guidance: '- fixture guidance for surface-only',
316
- specs: [
317
- { kind: 'read', name: 'getOpenScene', description: 'd', schema: {}, builder: () => null, doors: [] } as any
318
- ]
319
- })
294
+ test('a named door leaves no warning — what that door requires is asked at boot, not here', () => {
295
+ registerToolCategory(makeCategory('waiting', [{ name: 'twinNowTool', kind: 'read', doors: ['Query.twinNow'] }]))
320
296
 
321
297
  expect(getToolRegistryWarnings()).toEqual([])
322
298
  })
323
299
 
324
- test('★ a named door with no privilege of its own is its own fact, not an omission', () => {
325
- registerToolCategory({
326
- name: 'waiting',
327
- guidance: '- fixture guidance for waiting',
328
- specs: [
329
- { kind: 'read', name: 'twinNowTool', description: 'd', schema: {}, builder: () => null, doors: ['Query.twinNow'] } as any
330
- ]
331
- })
300
+ test('the message says what to write, so the person reading it does not have to look it up', () => {
301
+ registerToolCategory(makeCategory('no-door', [{ name: 'silent', kind: 'read' }]))
332
302
 
333
- expect(getToolRegistryWarnings()).toEqual([
334
- expect.objectContaining({
335
- code: 'tool-door-has-no-privilege',
336
- params: { category: 'waiting', tool: 'twinNowTool', kind: 'read', doors: ['Query.twinNow'] }
337
- })
338
- ])
303
+ expect(getToolRegistryWarnings()[0].message).toMatch(/doors: \[\]/)
339
304
  })
340
305
 
341
306
  test('re-registering a category replaces its warnings instead of piling them up', () => {
342
307
  const undeclared = { kind: 'write', name: 'w9', description: 'd', schema: {}, builder: () => null } as any
343
308
  registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
309
+ const once = getToolRegistryWarnings().map(w => w.code).sort()
310
+
344
311
  registerToolCategory({ name: 'again', guidance: '- g', specs: [undeclared] })
345
- expect(getToolRegistryWarnings()).toHaveLength(1)
312
+ /* 두 번 등록해도 한 번 등록한 것과 같아야 한다 — 축이 몇이든. */
313
+ expect(getToolRegistryWarnings().map(w => w.code).sort()).toEqual(once)
346
314
 
347
- registerToolCategory({ name: 'again', guidance: '- g', specs: [{ ...undeclared, privilege: { category: 'c', privilege: 'mutation' } }] })
315
+ registerToolCategory({ name: 'again', guidance: '- g', specs: [{ ...undeclared, doors: [] }] })
348
316
  expect(getToolRegistryWarnings()).toEqual([])
349
317
  })
350
318
  })