@things-factory/ai-client-base 10.1.29 → 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.
@@ -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,
@@ -23,16 +23,6 @@ afterEach(() => {
23
23
  })
24
24
 
25
25
  // 헬퍼 — 카테고리 fixture
26
- /**
27
- * 권한 축의 경고만 — 문 축(`tool-doors-undeclared`)은 자기 시험이 따로 본다.
28
- *
29
- * 한 시험이 두 축을 함께 세면, 한 축에 코드가 하나 늘 때마다 다른 축의 시험이 빨개진다. 그러면 고치는
30
- * 사람은 자기가 건드리지 않은 시험을 고치게 되고, 그 손질이 시험을 무디게 만든다.
31
- */
32
- function privilegeWarnings() {
33
- return getToolRegistryWarnings().filter(w => w.code !== 'tool-doors-undeclared')
34
- }
35
-
36
26
  function makeCategory(name: string, specs: Partial<ToolSpec>[]): ToolCategory {
37
27
  return {
38
28
  name,
@@ -44,8 +34,6 @@ function makeCategory(name: string, specs: Partial<ToolSpec>[]): ToolCategory {
44
34
  description: 'desc',
45
35
  schema: {},
46
36
  builder: () => ({}),
47
- /* A tool that changes something must name its door (A1). The fixture does, like guidance. */
48
- ...(s.kind && s.kind !== 'read' ? { privilege: { category: 'fixture', privilege: 'mutation' } } : {}),
49
37
  ...s
50
38
  })) as ToolSpec[]
51
39
  }
@@ -186,7 +174,7 @@ describe('registry — spec 보존', () => {
186
174
  kind: 'write',
187
175
  name: 't1',
188
176
  description: 'main desc',
189
- privilege: { category: 'fixture', privilege: 'mutation' },
177
+ doors: ['Mutation.t1'],
190
178
  themeNote: '⚠ note',
191
179
  schema: { type: 'object' },
192
180
  builder: () => null
@@ -272,107 +260,47 @@ describe('registry — 지침 없는 카테고리는 등록되지 않는다', ()
272
260
  })
273
261
  })
274
262
 
275
- describe('registry — a tool that changes something names its door (A1, stage one: surface)', () => {
276
- test.each(['write', 'action', 'external'] as const)(
277
- '★ a %s tool without a privilege registers, and the omission is recorded as a code',
278
- kind => {
279
- registerToolCategory({
280
- name: 'no-door',
281
- guidance: '- fixture guidance for no-door',
282
- specs: [{ kind, name: 'changesThings', description: 'd', schema: {}, builder: () => null } as any]
283
- })
284
- expect(getToolKind('changesThings')).toBe(kind)
285
- expect(privilegeWarnings()).toEqual([
286
- expect.objectContaining({
287
- code: 'tool-privilege-undeclared',
288
- params: { category: 'no-door', tool: 'changesThings', kind }
289
- })
290
- ])
291
- }
292
- )
293
-
294
- test('a privilege with no privilege name is recorded the same way', () => {
295
- registerToolCategory({
296
- name: 'half-door',
297
- guidance: '- fixture guidance for half-door',
298
- specs: [
299
- { kind: 'external', name: 'halfDoor', description: 'd', schema: {}, builder: () => null, privilege: { category: 'board' } } as any
300
- ]
301
- })
302
- expect(privilegeWarnings().map(w => w.params.tool)).toEqual(['halfDoor'])
303
- })
304
-
305
- test('a declared tool leaves no warning', () => {
306
- registerToolCategory(makeCategory('declared', [{ name: 'w1', kind: 'write' }]))
307
- expect(privilegeWarnings()).toEqual([])
308
- })
309
-
263
+ describe('registry — a tool names the doors it reaches (ADR-0071 decision 2)', () => {
310
264
  /*
311
- * 읽는 도구도 자기가 닿는 문을 말한다(ADR-0071 결정 2). 그전까지 업무 권한을 스스로 확인하는 것은
312
- * plant 뿐이었고, twin · figure 의 읽는 도구는 테넌트만 봤다 — 대화 문 권한만 있으면 그 자료를 읽었다.
265
+ * 문을 안 적은 도구는 그 답을 **아무도 못 읽게 만든다** — 쓴 사람 본인까지.
266
+ *
267
+ * ADR-0073 뒤로 답 행에 도구가 지난 문이 적히고, 선언이 없는 도구는 `unresolved:<도구>` 로 적혀
268
+ * 아무도 못 지난다. 2026-09-18 트윈 모델러에서 저작 대화의 답이 새로 고친 뒤 작성자에게
269
+ * 「이 답을 볼 권한이 없습니다」로 보였다. 2026-09-18 부터는 관문도 그 도구를 내보내지 않는다 —
270
+ * 이 목록이 「무엇이 빠져 있나」를 말하는 유일한 자리다.
313
271
  */
314
- test('★ a read tool that says nothing about its door is recorded too', () => {
315
- registerToolCategory(makeCategory('reads', [{ name: 'r1', kind: 'read' }]))
316
-
317
- expect(privilegeWarnings()).toEqual([
318
- expect.objectContaining({ code: 'tool-privilege-undeclared', params: { category: 'reads', tool: 'r1', kind: 'read' } })
319
- ])
320
- })
321
-
322
- test('★ `doors: []` is an answer — a tool that reaches no door has nothing to gate', () => {
272
+ test.each(['read', 'write', 'action', 'external'] as const)('★ a %s tool that declares no doors is reported by name', kind => {
323
273
  registerToolCategory({
324
- name: 'surface-only',
325
- guidance: '- fixture guidance for surface-only',
326
- specs: [
327
- { kind: 'read', name: 'getOpenScene', description: 'd', schema: {}, builder: () => null, doors: [] } as any
328
- ]
274
+ name: 'no-door',
275
+ guidance: '- fixture guidance for no-door',
276
+ specs: [{ kind, name: 'reachesSomething', description: 'd', schema: {}, builder: () => null } as any]
329
277
  })
330
278
 
331
- expect(privilegeWarnings()).toEqual([])
332
- })
333
-
334
- test('★ a named door with no privilege of its own is its own fact, not an omission', () => {
335
- registerToolCategory({
336
- name: 'waiting',
337
- guidance: '- fixture guidance for waiting',
338
- specs: [
339
- { kind: 'read', name: 'twinNowTool', description: 'd', schema: {}, builder: () => null, doors: ['Query.twinNow'] } as any
340
- ]
341
- })
342
-
343
- expect(privilegeWarnings()).toEqual([
279
+ expect(getToolKind('reachesSomething')).toBe(kind)
280
+ expect(getToolRegistryWarnings()).toEqual([
344
281
  expect.objectContaining({
345
- code: 'tool-door-has-no-privilege',
346
- params: { category: 'waiting', tool: 'twinNowTool', kind: 'read', doors: ['Query.twinNow'] }
282
+ code: 'tool-doors-undeclared',
283
+ params: { category: 'no-door', tool: 'reachesSomething', kind }
347
284
  })
348
285
  ])
349
286
  })
350
287
 
351
- /*
352
- * 문을 안 적은 도구는 그 답을 **아무도 못 읽게 만든다** — 쓴 사람 본인까지.
353
- *
354
- * ADR-0073 뒤로 답 행에 도구가 지난 문이 적히고, 선언이 없는 도구는 `unresolved:<도구>` 로 적혀
355
- * 아무도 못 지난다. 2026-09-18 트윈 모델러에서 저작 대화의 답이 새로 고친 뒤 작성자에게
356
- * 「이 답을 볼 권한이 없습니다」로 보였다.
357
- *
358
- * 부팅은 그때 아무 말도 안 했다. 권한 검사가 `privilege` 가 있으면 건너뛰는 자리에서 문 검사까지
359
- * 함께 건너뛰었기 때문이다 — 트윈의 저작 도구 넷이 정확히 그 모양이었다(권한 있음, 문 없음).
360
- */
361
- test('★ a tool that declares no doors is reported even when it declares a privilege', () => {
362
- registerToolCategory(
363
- makeCategory('authoring', [
364
- { name: 'proposeSceneEdit', kind: 'write', privilege: { category: 'board', privilege: 'mutation' } }
365
- ])
366
- )
288
+ test('★ `doors: []` is a declaration — it says this tool reaches none', () => {
289
+ registerToolCategory(makeCategory('surface', [{ name: 'getOpenScene', kind: 'read', doors: [] }]))
367
290
 
368
- const doors = getToolRegistryWarnings().filter(w => w.code === 'tool-doors-undeclared')
369
- expect(doors.map(w => w.params.tool)).toEqual(['proposeSceneEdit'])
291
+ expect(getToolRegistryWarnings()).toEqual([])
370
292
  })
371
293
 
372
- test('★ `doors: []` is a declaration — it says this tool reaches none', () => {
373
- registerToolCategory(makeCategory('surface', [{ name: 'getOpenScene', kind: 'read', doors: [] }]))
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'] }]))
296
+
297
+ expect(getToolRegistryWarnings()).toEqual([])
298
+ })
299
+
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' }]))
374
302
 
375
- expect(getToolRegistryWarnings().filter(w => w.code === 'tool-doors-undeclared')).toEqual([])
303
+ expect(getToolRegistryWarnings()[0].message).toMatch(/doors: \[\]/)
376
304
  })
377
305
 
378
306
  test('re-registering a category replaces its warnings instead of piling them up', () => {
@@ -384,7 +312,7 @@ describe('registry — a tool that changes something names its door (A1, stage o
384
312
  /* 두 번 등록해도 한 번 등록한 것과 같아야 한다 — 축이 몇이든. */
385
313
  expect(getToolRegistryWarnings().map(w => w.code).sort()).toEqual(once)
386
314
 
387
- registerToolCategory({ name: 'again', guidance: '- g', specs: [{ ...undeclared, privilege: { category: 'c', privilege: 'mutation' } }] })
388
- expect(privilegeWarnings()).toEqual([])
315
+ registerToolCategory({ name: 'again', guidance: '- g', specs: [{ ...undeclared, doors: [] }] })
316
+ expect(getToolRegistryWarnings()).toEqual([])
389
317
  })
390
318
  })
@@ -32,13 +32,15 @@ const categories: Map<string, ToolCategory> = new Map()
32
32
  /** A registration that was accepted but is not yet what the contract requires. Code + params, not prose. */
33
33
  export interface ToolRegistryWarning {
34
34
  /**
35
- * `tool-privilege-undeclared` — nothing said which door this tool reaches.
36
- * `tool-door-has-no-privilege` — the door was named and it declares no privilege of its own yet
37
- * (twin's queries, waiting on AUTH stage two). The tool cannot be stricter than its door, so
38
- * this is a different fact from saying nothing, and the report keeps the two apart.
35
+ * `tool-doors-undeclared` — nothing said which doors this tool reaches, so the gate leaves it out
36
+ * of every conversation (`resolveToolScope`) and this names it.
37
+ *
38
+ * What those doors require is **not** asked here. Registration runs while modules load, before the
39
+ * schema stands, so AUTH's field table is still empty; `ai-assistant`'s boot report asks the
40
+ * schema after it is built (`tool-door-not-in-schema` · `tool-door-has-no-privilege`).
39
41
  */
40
- code: 'tool-privilege-undeclared' | 'tool-door-has-no-privilege' | 'tool-doors-undeclared'
41
- params: { category: string; tool: string; kind: ToolSpec['kind']; doors?: string[] }
42
+ code: 'tool-doors-undeclared'
43
+ params: { category: string; tool: string; kind: ToolSpec['kind'] }
42
44
  /** For a person reading it; the code and params are what callers act on. */
43
45
  message: string
44
46
  }
@@ -46,8 +48,8 @@ export interface ToolRegistryWarning {
46
48
  const warnings: ToolRegistryWarning[] = []
47
49
 
48
50
  /**
49
- * What registration accepted but the contract does not yet allow. Stage one of A1 records these
50
- * instead of refusing; a host shows them at boot, and when the list is empty the rule becomes a refusal.
51
+ * What registered without saying which doors it reaches. A host prints this at boot; the gate has
52
+ * already left each of these tools out of every conversation.
51
53
  */
52
54
  export function getToolRegistryWarnings(): ReadonlyArray<ToolRegistryWarning> {
53
55
  return warnings.slice()
@@ -80,19 +82,17 @@ export function registerToolCategory(category: ToolCategory): void {
80
82
  }
81
83
 
82
84
  /*
83
- * A tool that changes something must say which privilege it goes through (A1, 2026-09-17).
85
+ * A tool says which doors it reaches (ADR-0071 decision 2).
84
86
  *
85
- * The conversation door checks one privilege — may this person talk to the assistant. Without a
86
- * privilege on the tool, that single check opened every tool behind it: a user holding only
87
- * `ai-assistant:mutation` could reach board-import's `materializeImportSession` and create a
88
- * Board.
87
+ * The conversation door checks one privilege — may this person talk to the assistant. On its own
88
+ * that single check opened every tool behind it: a user holding only `ai-assistant:mutation` could
89
+ * reach board-import's `materializeImportSession` and create a Board.
89
90
  *
90
- * **This is stage one of two: surface, then refuse** (ADR-0037, "먼저 드러내고, 목록이 비면 막는다").
91
- * Refusing now would stop every app whose tools predate the `privilege` field from booting, and
92
- * those apps compile against an installed copy that does not have the field yet. So an undeclared
93
- * tool still registers, and the omission is recorded as a code (`getToolRegistryWarnings`), not
94
- * a log line — a warning only a log carries is not distinguishable from no warning. When the list
95
- * is empty across the apps, this becomes a refusal.
91
+ * Registration does not refuse a tool that says nothing; it records the omission as a code
92
+ * (`getToolRegistryWarnings`), not a log line — a warning only a log carries is not
93
+ * distinguishable from no warning. The refusal happens where it costs nothing to be late: the gate
94
+ * leaves an undeclared tool out of every conversation, so booting stays possible while the list is
95
+ * worked down, and no undeclared tool is reachable in the meantime.
96
96
  */
97
97
  warnings.splice(0, warnings.length, ...warnings.filter(w => w.params.category !== category.name))
98
98
 
@@ -103,9 +103,8 @@ export function registerToolCategory(category: ToolCategory): void {
103
103
  * `unresolved:<도구>` 로 적히고 그 이름은 아무도 못 지난다 — **답을 쓴 본인도** 새로 고치면
104
104
  * 「이 답을 볼 권한이 없습니다」를 본다. 2026-09-18 트윈 모델러에서 그렇게 나왔다.
105
105
  *
106
- * 이 검사를 권한 검사와 따로 도는 이유가 그것이다. 아래 고리는 `privilege` 가 있으면 건너뛰는데,
107
- * 문을 안 적은 해는 권한을 적었는지와 상관이 없다. 트윈의 저작 도구 넷이 정확히 그 자리였다 —
108
- * 권한은 있고 문은 없어서 부팅이 아무 말도 안 했다.
106
+ * 2026-09-18 부터 이 선언은 관문이기도 하다(ADR-0071 결정 2 보탬). 문을 안 적은 도구는 어느 대화에도
107
+ * 나가지 않으므로, 이 목록이 「무엇이 빠져 있나」를 말하는 유일한 자리다.
109
108
  */
110
109
  for (const spec of category.specs) {
111
110
  if (Array.isArray(spec.doors)) continue
@@ -114,45 +113,9 @@ export function registerToolCategory(category: ToolCategory): void {
114
113
  code: 'tool-doors-undeclared',
115
114
  params: { category: category.name, tool: spec.name, kind: spec.kind },
116
115
  message:
117
- `tool '${spec.name}' in category '${category.name}' declares no doors, so answers built with it ` +
118
- 'are hidden from every reader including their author — declare `doors`, or `doors: []` when it reaches none'
119
- })
120
- }
121
-
122
- for (const spec of category.specs) {
123
- const declared = Boolean(spec.privilege?.category && spec.privilege?.privilege)
124
- if (declared) continue
125
-
126
- /*
127
- * An empty list says this tool reaches no door — the ones that hand back what the surface sent
128
- * are that. Nothing to gate, nothing to report.
129
- */
130
- if (Array.isArray(spec.doors) && spec.doors.length === 0) continue
131
-
132
- if (spec.doors?.length) {
133
- /*
134
- * The door is named and carries no privilege yet. The tool cannot be stricter than the door
135
- * it reaches, so this is not an omission by whoever wrote the tool — it is the door's turn
136
- * (ADR-0071 decision 2). Reported under its own code so the two are not read as one list.
137
- */
138
- warnings.push({
139
- code: 'tool-door-has-no-privilege',
140
- params: { category: category.name, tool: spec.name, kind: spec.kind, doors: spec.doors },
141
- message:
142
- `tool '${spec.name}' reaches ${spec.doors.join(' + ')} and declares no privilege of its own — ` +
143
- 'whether those doors require anything is asked at boot, against the schema, not copied here ' +
144
- '(ADR-0072 decision 5)'
145
- })
146
- continue
147
- }
148
-
149
- warnings.push({
150
- code: 'tool-privilege-undeclared',
151
- params: { category: category.name, tool: spec.name, kind: spec.kind },
152
- message:
153
- `tool '${spec.name}' in category '${category.name}' declares no privilege of its own — ` +
154
- 'a tool that changes something says who may run it. What the doors it reaches require is a ' +
155
- "separate question, answered at boot against the schema (`tool-door-not-in-schema`)."
116
+ `tool '${spec.name}' in category '${category.name}' declares no doors, so it is offered to no ` +
117
+ 'conversation and answers built with it would be hidden from every reader including their ' +
118
+ 'author — declare `doors`, or `doors: []` when it reaches none'
156
119
  })
157
120
  }
158
121
 
@@ -119,23 +119,6 @@ export interface ExternalToolContext {
119
119
  }
120
120
  // ── ToolSpec — 카테고리별 변형 ──────────────────────────────────────
121
121
 
122
- /**
123
- * The privilege a tool requires — the same `category`/`privilege` the tool's GraphQL door declares
124
- * (`@privilege(category, privilege, owner?, super?)`), so the fields mirror `PrivilegeObject`.
125
- *
126
- * A tool is not a new door; it is another way into a door that already exists. A tool that creates
127
- * a Board goes through the privilege that creating a Board already needs. Minting an AI-only
128
- * privilege name would put two doors on one effect, and one of them would end up looser.
129
- */
130
- export interface ToolPrivilege {
131
- category: string
132
- privilege: string
133
- /** Domain owners pass without the privilege — mirror the door's `domainOwnerGranted`. */
134
- owner?: boolean
135
- /** Super users pass without the privilege — mirror the door's `superUserGranted`. */
136
- super?: boolean
137
- }
138
-
139
122
  /**
140
123
  * 모든 ToolSpec 공통 메타데이터.
141
124
  */
@@ -151,14 +134,6 @@ export interface ToolSpecBase {
151
134
  themeNote?: string
152
135
  /** JSON Schema for parameters — LLM tool schema 에 그대로 노출. */
153
136
  schema: any
154
- /**
155
- * The privilege a caller needs before this tool is offered or run. See `ToolPrivilege`.
156
- *
157
- * Expected for `write`, `action` and `external` — registration records a
158
- * `tool-privilege-undeclared` warning for one without it (A1 stage one).
159
- */
160
- privilege?: ToolPrivilege
161
-
162
137
  /**
163
138
  * Which GraphQL doors this tool reaches — `['Query.twinNow']`,
164
139
  * `['Query.productionSummary', 'Query.pendingSummary']`.
@@ -173,15 +148,23 @@ export interface ToolSpecBase {
173
148
  * plant's factory overview answers from two, and asking for only one of them would hand back
174
149
  * half of what the caller may not see.
175
150
  *
176
- * Three states, and the boot report keeps them apart:
177
- * - doors and a privilege — the gate checks it
178
- * - `doors: []` — this tool reaches no door at all. The ones that hand back what the surface
179
- * sent (`getOpenScene`, `getStationScreen`) are this: there is nothing behind them to gate.
180
- * - doors with no privilege — the doors themselves declare none yet (twin's queries, waiting
181
- * on AUTH stage two). A tool cannot be stricter than the door it reaches.
151
+ * **This list is the gate** (ADR-0071 decision 2, added to 2026-09-18). A tool used to carry its
152
+ * own copy of what its door asks; the copy went stale the day a door changed its declaration, and
153
+ * the looser of the two won. Now the gate reads the door's own declaration from AUTH's field
154
+ * table (`privilegeOfField`) and asks `checkPermission` with it — the same question, the same
155
+ * answer, one declaration.
156
+ *
157
+ * Three states, and they are not the same thing:
158
+ * - **doors named** — the caller passes each of them, or the tool is neither offered nor run.
159
+ * - **`doors: []`** — this tool reaches no door at all. The ones that hand back what the surface
160
+ * sent (`getOpenScene`) are this: there is nothing behind them to gate.
161
+ * - **no `doors` key** — nothing was said. This is **not** an open tool: it is left out of every
162
+ * conversation and reported by name at boot (`tool-doors-undeclared`). An answer built with it
163
+ * would be hidden from every reader including its author (ADR-0073), so opening it would
164
+ * produce a tool whose answers no one can read.
182
165
  *
183
- * The privilege is **the door's**, copied, and a test in each app compares the copy with the
184
- * directive on that field. When the schema can be read at boot, the copy goes away.
166
+ * A door that declares nothing of its own opens as far as that door opens — a tool is never
167
+ * stricter than the door it reaches, and never softer.
185
168
  */
186
169
  doors?: string[]
187
170
  }