@stigmer/server 3.23.0 → 3.24.0

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 (272) hide show
  1. package/dist/authorization/authorizer.d.ts +4 -8
  2. package/dist/authorization/authorizer.d.ts.map +1 -1
  3. package/dist/authorization/authorizer.js +5 -9
  4. package/dist/authorization/authorizer.js.map +1 -1
  5. package/dist/authorization/derived-tuples.d.ts.map +1 -1
  6. package/dist/authorization/derived-tuples.js +10 -25
  7. package/dist/authorization/derived-tuples.js.map +1 -1
  8. package/dist/authorization/evaluator.d.ts +7 -8
  9. package/dist/authorization/evaluator.d.ts.map +1 -1
  10. package/dist/authorization/evaluator.js +4 -20
  11. package/dist/authorization/evaluator.js.map +1 -1
  12. package/dist/authorization/list-read-scope.d.ts.map +1 -1
  13. package/dist/authorization/list-read-scope.js +5 -9
  14. package/dist/authorization/list-read-scope.js.map +1 -1
  15. package/dist/authorization/model/agent.d.ts.map +1 -1
  16. package/dist/authorization/model/agent.js +4 -5
  17. package/dist/authorization/model/agent.js.map +1 -1
  18. package/dist/authorization/model/agent_instance.d.ts.map +1 -1
  19. package/dist/authorization/model/agent_instance.js +2 -2
  20. package/dist/authorization/model/agent_instance.js.map +1 -1
  21. package/dist/authorization/model/mcp_server.d.ts.map +1 -1
  22. package/dist/authorization/model/mcp_server.js +2 -2
  23. package/dist/authorization/model/mcp_server.js.map +1 -1
  24. package/dist/authorization/model/plugin.d.ts.map +1 -1
  25. package/dist/authorization/model/plugin.js +3 -4
  26. package/dist/authorization/model/plugin.js.map +1 -1
  27. package/dist/authorization/model/rewrite.d.ts +8 -10
  28. package/dist/authorization/model/rewrite.d.ts.map +1 -1
  29. package/dist/authorization/model/rewrite.js +0 -3
  30. package/dist/authorization/model/rewrite.js.map +1 -1
  31. package/dist/authorization/model/skill.d.ts.map +1 -1
  32. package/dist/authorization/model/skill.js +2 -2
  33. package/dist/authorization/model/skill.js.map +1 -1
  34. package/dist/authorization/model/workflow.d.ts.map +1 -1
  35. package/dist/authorization/model/workflow.js +2 -2
  36. package/dist/authorization/model/workflow.js.map +1 -1
  37. package/dist/authorization/model/workflow_instance.d.ts.map +1 -1
  38. package/dist/authorization/model/workflow_instance.js +2 -2
  39. package/dist/authorization/model/workflow_instance.js.map +1 -1
  40. package/dist/authorization/organization-directory.d.ts.map +1 -1
  41. package/dist/authorization/organization-directory.js +2 -3
  42. package/dist/authorization/organization-directory.js.map +1 -1
  43. package/dist/authorization/store-test-kit.d.ts +0 -6
  44. package/dist/authorization/store-test-kit.d.ts.map +1 -1
  45. package/dist/authorization/store-test-kit.js +16 -62
  46. package/dist/authorization/store-test-kit.js.map +1 -1
  47. package/dist/authorization/tuples.d.ts +13 -32
  48. package/dist/authorization/tuples.d.ts.map +1 -1
  49. package/dist/authorization/tuples.js +3 -11
  50. package/dist/authorization/tuples.js.map +1 -1
  51. package/dist/domain/agent/controller.d.ts.map +1 -1
  52. package/dist/domain/agent/controller.js +4 -4
  53. package/dist/domain/agent/controller.js.map +1 -1
  54. package/dist/domain/agentchannel/controller.d.ts.map +1 -1
  55. package/dist/domain/agentchannel/controller.js +3 -1
  56. package/dist/domain/agentchannel/controller.js.map +1 -1
  57. package/dist/domain/agentexecution/controller.d.ts.map +1 -1
  58. package/dist/domain/agentexecution/controller.js +3 -1
  59. package/dist/domain/agentexecution/controller.js.map +1 -1
  60. package/dist/domain/agentinstance/controller.d.ts.map +1 -1
  61. package/dist/domain/agentinstance/controller.js +3 -4
  62. package/dist/domain/agentinstance/controller.js.map +1 -1
  63. package/dist/domain/agentshare/constants.d.ts +6 -11
  64. package/dist/domain/agentshare/constants.d.ts.map +1 -1
  65. package/dist/domain/agentshare/constants.js +8 -16
  66. package/dist/domain/agentshare/constants.js.map +1 -1
  67. package/dist/domain/agentshare/controller.d.ts.map +1 -1
  68. package/dist/domain/agentshare/controller.js +3 -1
  69. package/dist/domain/agentshare/controller.js.map +1 -1
  70. package/dist/domain/agentshare/steps.d.ts +18 -25
  71. package/dist/domain/agentshare/steps.d.ts.map +1 -1
  72. package/dist/domain/agentshare/steps.js +41 -142
  73. package/dist/domain/agentshare/steps.js.map +1 -1
  74. package/dist/domain/environment/controller.d.ts.map +1 -1
  75. package/dist/domain/environment/controller.js +2 -3
  76. package/dist/domain/environment/controller.js.map +1 -1
  77. package/dist/domain/executioncontext/controller.d.ts.map +1 -1
  78. package/dist/domain/executioncontext/controller.js +2 -1
  79. package/dist/domain/executioncontext/controller.js.map +1 -1
  80. package/dist/domain/mcpserver/controller.d.ts.map +1 -1
  81. package/dist/domain/mcpserver/controller.js +3 -4
  82. package/dist/domain/mcpserver/controller.js.map +1 -1
  83. package/dist/domain/plugin/controller.d.ts.map +1 -1
  84. package/dist/domain/plugin/controller.js +1 -3
  85. package/dist/domain/plugin/controller.js.map +1 -1
  86. package/dist/domain/plugin/push.d.ts +7 -5
  87. package/dist/domain/plugin/push.d.ts.map +1 -1
  88. package/dist/domain/plugin/push.js +8 -14
  89. package/dist/domain/plugin/push.js.map +1 -1
  90. package/dist/domain/schedule/controller.d.ts.map +1 -1
  91. package/dist/domain/schedule/controller.js +3 -1
  92. package/dist/domain/schedule/controller.js.map +1 -1
  93. package/dist/domain/session/controller.d.ts.map +1 -1
  94. package/dist/domain/session/controller.js +3 -2
  95. package/dist/domain/session/controller.js.map +1 -1
  96. package/dist/domain/skill/controller.d.ts.map +1 -1
  97. package/dist/domain/skill/controller.js +0 -2
  98. package/dist/domain/skill/controller.js.map +1 -1
  99. package/dist/domain/workflow/agent-call-references.d.ts +81 -0
  100. package/dist/domain/workflow/agent-call-references.d.ts.map +1 -0
  101. package/dist/domain/workflow/agent-call-references.js +117 -0
  102. package/dist/domain/workflow/agent-call-references.js.map +1 -0
  103. package/dist/domain/workflow/controller.d.ts.map +1 -1
  104. package/dist/domain/workflow/controller.js +14 -6
  105. package/dist/domain/workflow/controller.js.map +1 -1
  106. package/dist/domain/workflow/registry/data/task-kind-registry.json +3 -3
  107. package/dist/domain/workflow/validation/task-config-constraints.d.ts +10 -1
  108. package/dist/domain/workflow/validation/task-config-constraints.d.ts.map +1 -1
  109. package/dist/domain/workflow/validation/task-config-constraints.js +3 -2
  110. package/dist/domain/workflow/validation/task-config-constraints.js.map +1 -1
  111. package/dist/domain/workflowexecution/controller.d.ts.map +1 -1
  112. package/dist/domain/workflowexecution/controller.js +4 -2
  113. package/dist/domain/workflowexecution/controller.js.map +1 -1
  114. package/dist/domain/workflowinstance/controller.d.ts.map +1 -1
  115. package/dist/domain/workflowinstance/controller.js +3 -4
  116. package/dist/domain/workflowinstance/controller.js.map +1 -1
  117. package/dist/extensions/authorization-queries.d.ts +6 -5
  118. package/dist/extensions/authorization-queries.d.ts.map +1 -1
  119. package/dist/extensions/list-read-scope.d.ts +1 -1
  120. package/dist/extensions/resource-authorization.d.ts +3 -2
  121. package/dist/extensions/resource-authorization.d.ts.map +1 -1
  122. package/dist/index.d.ts +1 -1
  123. package/dist/index.d.ts.map +1 -1
  124. package/dist/index.js.map +1 -1
  125. package/dist/pipeline/apiresource-labels.d.ts +10 -9
  126. package/dist/pipeline/apiresource-labels.d.ts.map +1 -1
  127. package/dist/pipeline/apiresource-labels.js +10 -9
  128. package/dist/pipeline/apiresource-labels.js.map +1 -1
  129. package/dist/pipeline/apiresource-meta.d.ts +12 -3
  130. package/dist/pipeline/apiresource-meta.d.ts.map +1 -1
  131. package/dist/pipeline/apiresource-meta.js +21 -10
  132. package/dist/pipeline/apiresource-meta.js.map +1 -1
  133. package/dist/pipeline/steps/authorization-tuples.d.ts +9 -3
  134. package/dist/pipeline/steps/authorization-tuples.d.ts.map +1 -1
  135. package/dist/pipeline/steps/authorization-tuples.js +9 -11
  136. package/dist/pipeline/steps/authorization-tuples.js.map +1 -1
  137. package/dist/pipeline/steps/references.d.ts +204 -20
  138. package/dist/pipeline/steps/references.d.ts.map +1 -1
  139. package/dist/pipeline/steps/references.js +356 -43
  140. package/dist/pipeline/steps/references.js.map +1 -1
  141. package/dist/query/search/controller.js +0 -1
  142. package/dist/query/search/controller.js.map +1 -1
  143. package/dist/query/search/criteria.d.ts +1 -5
  144. package/dist/query/search/criteria.d.ts.map +1 -1
  145. package/dist/query/search/criteria.js +3 -13
  146. package/dist/query/search/criteria.js.map +1 -1
  147. package/dist/query/search/handler.d.ts +5 -4
  148. package/dist/query/search/handler.d.ts.map +1 -1
  149. package/dist/query/search/handler.js +7 -6
  150. package/dist/query/search/handler.js.map +1 -1
  151. package/dist/query/search/query-store.d.ts.map +1 -1
  152. package/dist/query/search/query-store.js +0 -2
  153. package/dist/query/search/query-store.js.map +1 -1
  154. package/dist/store/interface.d.ts +3 -6
  155. package/dist/store/interface.d.ts.map +1 -1
  156. package/dist/store/interface.js.map +1 -1
  157. package/dist/store/postgres/migrations.d.ts +3 -1
  158. package/dist/store/postgres/migrations.d.ts.map +1 -1
  159. package/dist/store/postgres/migrations.js +35 -1
  160. package/dist/store/postgres/migrations.js.map +1 -1
  161. package/dist/store/postgres/store.d.ts.map +1 -1
  162. package/dist/store/postgres/store.js +4 -9
  163. package/dist/store/postgres/store.js.map +1 -1
  164. package/dist/store/public-visibility-retired.d.ts +67 -0
  165. package/dist/store/public-visibility-retired.d.ts.map +1 -0
  166. package/dist/store/public-visibility-retired.js +55 -0
  167. package/dist/store/public-visibility-retired.js.map +1 -0
  168. package/dist/store/sqlite/migrations.d.ts +3 -1
  169. package/dist/store/sqlite/migrations.d.ts.map +1 -1
  170. package/dist/store/sqlite/migrations.js +36 -1
  171. package/dist/store/sqlite/migrations.js.map +1 -1
  172. package/dist/store/sqlite/store.d.ts.map +1 -1
  173. package/dist/store/sqlite/store.js +4 -8
  174. package/dist/store/sqlite/store.js.map +1 -1
  175. package/package.json +6 -6
  176. package/src/authorization/README.md +24 -26
  177. package/src/authorization/__tests__/authorizer.test.ts +10 -9
  178. package/src/authorization/__tests__/derived-tuples.test.ts +4 -11
  179. package/src/authorization/__tests__/evaluator.test.ts +14 -73
  180. package/src/authorization/__tests__/facts.test.ts +2 -2
  181. package/src/authorization/__tests__/fixtures/fga/agent-instance-creation.fga.yaml +19 -27
  182. package/src/authorization/__tests__/fixtures/fga/blueprint-private-visibility.fga.yaml +6 -98
  183. package/src/authorization/__tests__/fixtures/fga/default-instance-inheritance.fga.yaml +4 -61
  184. package/src/authorization/__tests__/fixtures/fga/mcp-server-authoring.fga.yaml +1 -15
  185. package/src/authorization/__tests__/fixtures/fga/org-admin-owner-inheritance.fga.yaml +1 -21
  186. package/src/authorization/__tests__/fixtures/fga/platform-visibility.fga.yaml +1 -25
  187. package/src/authorization/__tests__/fixtures/fga/workflow-execution-sharing.fga.yaml +1 -3
  188. package/src/authorization/__tests__/list-read-scope.test.ts +18 -17
  189. package/src/authorization/__tests__/store-tests.test.ts +2 -6
  190. package/src/authorization/__tests__/tuples.test.ts +12 -11
  191. package/src/authorization/authorizer.ts +4 -9
  192. package/src/authorization/derived-tuples.ts +14 -31
  193. package/src/authorization/evaluator.ts +8 -44
  194. package/src/authorization/list-read-scope.ts +5 -11
  195. package/src/authorization/model/agent.ts +2 -5
  196. package/src/authorization/model/agent_instance.ts +0 -2
  197. package/src/authorization/model/mcp_server.ts +0 -2
  198. package/src/authorization/model/plugin.ts +1 -4
  199. package/src/authorization/model/rewrite.ts +8 -13
  200. package/src/authorization/model/skill.ts +0 -2
  201. package/src/authorization/model/workflow.ts +0 -2
  202. package/src/authorization/model/workflow_instance.ts +0 -2
  203. package/src/authorization/organization-directory.ts +1 -3
  204. package/src/authorization/store-test-kit.ts +16 -75
  205. package/src/authorization/tuples.ts +16 -39
  206. package/src/domain/agent/__tests__/agent.test.ts +11 -4
  207. package/src/domain/agent/controller.ts +7 -7
  208. package/src/domain/agentchannel/__tests__/agentchannel.test.ts +223 -48
  209. package/src/domain/agentchannel/controller.ts +6 -1
  210. package/src/domain/agentexecution/controller.ts +6 -1
  211. package/src/domain/agentinstance/controller.ts +6 -12
  212. package/src/domain/agentshare/__tests__/agentshare.test.ts +196 -172
  213. package/src/domain/agentshare/__tests__/create-authorization.test.ts +16 -31
  214. package/src/domain/agentshare/constants.ts +14 -26
  215. package/src/domain/agentshare/controller.ts +6 -1
  216. package/src/domain/agentshare/steps.ts +41 -194
  217. package/src/domain/environment/controller.ts +5 -8
  218. package/src/domain/executioncontext/controller.ts +5 -1
  219. package/src/domain/mcpserver/__tests__/mcpserver.test.ts +115 -28
  220. package/src/domain/mcpserver/__tests__/oauth-handshake.test.ts +53 -18
  221. package/src/domain/mcpserver/controller.ts +6 -12
  222. package/src/domain/plugin/controller.ts +1 -8
  223. package/src/domain/plugin/push.ts +8 -16
  224. package/src/domain/schedule/controller.ts +6 -1
  225. package/src/domain/session/controller.ts +12 -4
  226. package/src/domain/skill/controller.ts +0 -7
  227. package/src/domain/workflow/__tests__/agent-call-references.test.ts +373 -0
  228. package/src/domain/workflow/agent-call-references.ts +204 -0
  229. package/src/domain/workflow/controller.ts +28 -10
  230. package/src/domain/workflow/registry/data/task-kind-registry.json +3 -3
  231. package/src/domain/workflow/validation/task-config-constraints.ts +7 -3
  232. package/src/domain/workflowexecution/controller.ts +7 -2
  233. package/src/domain/workflowinstance/controller.ts +6 -12
  234. package/src/extensions/__tests__/built-in-authorization-composed.test.ts +32 -19
  235. package/src/extensions/__tests__/extension-composition.test.ts +4 -4
  236. package/src/extensions/__tests__/list-read-scope-composed.test.ts +9 -6
  237. package/src/extensions/authorization-queries.ts +6 -5
  238. package/src/extensions/list-read-scope.ts +1 -1
  239. package/src/extensions/resource-authorization.ts +3 -5
  240. package/src/index.ts +0 -1
  241. package/src/pipeline/__tests__/supports-visibility.test.ts +112 -0
  242. package/src/pipeline/apiresource-labels.ts +10 -9
  243. package/src/pipeline/apiresource-meta.ts +21 -10
  244. package/src/pipeline/steps/__tests__/authorization-tuples.test.ts +18 -31
  245. package/src/pipeline/steps/__tests__/references.test.ts +737 -0
  246. package/src/pipeline/steps/authorization-tuples.ts +9 -11
  247. package/src/pipeline/steps/references.ts +622 -70
  248. package/src/query/search/__tests__/criteria.test.ts +0 -4
  249. package/src/query/search/__tests__/query-store.test.ts +11 -40
  250. package/src/query/search/controller.ts +0 -1
  251. package/src/query/search/criteria.ts +0 -14
  252. package/src/query/search/handler.ts +6 -7
  253. package/src/query/search/query-store.ts +0 -2
  254. package/src/store/__tests__/public-visibility-retired.test.ts +127 -0
  255. package/src/store/__tests__/store-contract.ts +6 -51
  256. package/src/store/interface.ts +3 -6
  257. package/src/store/postgres/__tests__/migrations.test.ts +187 -6
  258. package/src/store/postgres/__tests__/store-contract.test.ts +0 -2
  259. package/src/store/postgres/migrations.ts +48 -1
  260. package/src/store/postgres/store.ts +4 -11
  261. package/src/store/public-visibility-retired.ts +119 -0
  262. package/src/store/sqlite/__tests__/migrations.test.ts +191 -5
  263. package/src/store/sqlite/migrations.ts +45 -1
  264. package/src/store/sqlite/store.ts +4 -10
  265. package/dist/pipeline/steps/visibility-gates.d.ts +0 -27
  266. package/dist/pipeline/steps/visibility-gates.d.ts.map +0 -1
  267. package/dist/pipeline/steps/visibility-gates.js +0 -139
  268. package/dist/pipeline/steps/visibility-gates.js.map +0 -1
  269. package/src/authorization/__tests__/fixtures/fga/cross-org-share-creation.fga.yaml +0 -98
  270. package/src/authorization/__tests__/fixtures/fga/public-visibility-setter.fga.yaml +0 -52
  271. package/src/pipeline/steps/__tests__/visibility-gates.test.ts +0 -187
  272. package/src/pipeline/steps/visibility-gates.ts +0 -174
@@ -1,36 +1,505 @@
1
1
  /**
2
- * NormalizeReferences + ValidateReferences — port
3
- * steps/normalize_references.go and steps/validate_references.go, sharing
4
- * one spec walker (as the Go files share theirs).
2
+ * References — the two shared steps every chain that stores a spec with
3
+ * `ApiResourceReference` fields runs, and the ONE rule that decides whether
4
+ * a reference may be written.
5
5
  *
6
6
  * NormalizeReferences fills EMPTY org fields in ApiResourceReference
7
7
  * messages inside the spec from the resource's own metadata.org, so stored
8
- * references are absolute; explicit orgs (cross-org refs) are preserved.
9
- * Only the spec is walked — status is system-generated and already
10
- * absolute. Runs after BuildNewState/BuildUpdateState, before Persist.
8
+ * references are absolute; an explicit org is preserved. Only the spec is
9
+ * walked — status is system-generated and already absolute. Runs after
10
+ * BuildNewState/BuildUpdateState, before Persist.
11
11
  *
12
- * ValidateReferences verifies spec references point at existing resources
13
- * — strict FAILED_PRECONDITION for missing MCP servers (an agent whose MCP
14
- * server is missing cannot execute its declared tools). Runs AFTER
15
- * NormalizeReferences so orgs are resolved.
12
+ * ValidateReferences runs directly after it and asks `checkReference` for
13
+ * every reference the walk finds. The rule has three clauses, in the
14
+ * order they are asked:
15
+ *
16
+ * (i) A reference with no organization is refused. After the normalize
17
+ * step the only way an org is still empty is a resource that has
18
+ * none itself; a slug looked up with no org matches any
19
+ * organization's row (helpers.ts), which is the cross-tenant read
20
+ * the reference lane's `requireOrgForReference` exists to forbid.
21
+ * (ii) Same organization as the resource: the target must exist, and,
22
+ * when the target's kind is one the RUN reads as the person
23
+ * (`readByRun` in the table below), its visibility must be at least
24
+ * the resource's on the order private < org < platform — the FLOOR.
25
+ * What a person can run they must also be able to read: an
26
+ * org-visible agent over a private MCP server would run for every
27
+ * member and be readable by one. Environments, OAuth apps and
28
+ * channel apps are resolved by the server on the run's behalf and
29
+ * never read by the person, so their level is not a leak the floor
30
+ * closes, and an org-visible instance may hold a private personal
31
+ * environment as it always has. A RELATIVE reference (below) is
32
+ * compared against the resource's level capped at org.
33
+ * (iii) Another organization: the target must exist AND be
34
+ * platform-visible, answered with ONE sentence that does not say
35
+ * which failed. The caller has no standing to learn what another
36
+ * organization holds (the anti-probe posture the AgentShare lane set
37
+ * first). The rule reads the target's LEVEL, not tenancy: whether
38
+ * the writer's organization is one the target's identity provider
39
+ * links is the run-time authorizer's question, asked when the run
40
+ * reads the target, and the copy here claims no more than the rule
41
+ * checks. No Environment, OAuth app or channel app is ever
42
+ * platform-visible, so every cross-organization reference to one is
43
+ * refused here.
44
+ *
45
+ * The kind a reference names is the kind its FIELD declares
46
+ * (`reference_kind`, the contract's word for what the field points to),
47
+ * never the `kind` value a client happened to put on the message: the
48
+ * runtime resolves `skill_refs` as skills whatever the message says, and
49
+ * the rule must agree with the runtime. A reference with an empty slug is
50
+ * unset and skipped (the field-level CEL rules own "required").
51
+ *
52
+ * A reference is RELATIVE when it names no organization of its own and
53
+ * the runtime resolves it in the organization the resource RUNS in, not
54
+ * the one it is stored in. Exactly one exists: a workflow `agent_call`
55
+ * task's bare slug (domain/workflow/agent-call-references.ts), which the
56
+ * runner resolves in the execution's organization; any workflow a person
57
+ * may execute can run from another organization by id. The rule judges
58
+ * it in the resource's own organization — the one its author can see, so
59
+ * the target must exist there — and caps its floor at org: only the
60
+ * running organization's own people ever read a target through it, and
61
+ * they can read an org-visible one. The cap changes nothing for a
62
+ * private or org resource and lets a platform-visible workflow call its
63
+ * organization's org-visible agent. A value fixed only at run (an
64
+ * `agent_call` agent holding a runtime expression) is not a reference at
65
+ * all: the collector yields nothing for it, and the run reads the
66
+ * resolved target as the person the run acts as, as it reads every
67
+ * id-bound binding.
68
+ *
69
+ * The floor has a second door. Raising a resource's level through
70
+ * updateVisibility could open the same gap a create cannot, so
71
+ * GuardReferenceFloorOnEscalation runs on the two chains whose rows carry
72
+ * run-read references (agent, workflow) after ValidateVisibilityUpdate:
73
+ * when the requested level is above the stored one, every run-read
74
+ * reference the STORED row carries must be at least the requested level
75
+ * (a relative one, at least the requested level capped at org), judged by
76
+ * the same function over the same collectors, else the escalation is
77
+ * refused naming the dependencies. Only the floor is asked at
78
+ * that door — a dependency that has since left or stopped being shared was
79
+ * judged when the row was written and is the run's to refuse.
80
+ *
81
+ * REFERENCE_TARGET_KINDS is the one explicit table of kinds a spec may
82
+ * reference, with each kind's schema and its `readByRun` flag: the
83
+ * composition-root idiom (query/search/registry.ts) — an explicit list a
84
+ * test pins against the protos, so a new `reference_kind` in the contract
85
+ * fails the pin until this table says how it is read.
86
+ *
87
+ * Cost: one `listResources` scan per referenced KIND per write (not per
88
+ * reference), decoded once into a (org, slug) index; the same scan every
89
+ * runtime path already pays per reference when it resolves the target at
90
+ * run start. A store read by (kind, org, slug) is the follow-on that
91
+ * removes the scan everywhere, and is not this module's.
92
+ *
93
+ * The refusal copy is exported below. The MCP-server sentence predates the
94
+ * rule and is wire contract (byte-pinned by pipeline/__tests__/steps.test.ts
95
+ * and the agent conformance suite); its siblings for the other kinds take
96
+ * the same shape. `collectSpecReferences` is the walk exported for the
97
+ * readers that ask the reverse question ("who references this?").
16
98
  */
17
- import type { DescMessage } from "@bufbuild/protobuf";
99
+ import type { DescField, DescMessage, Message } from "@bufbuild/protobuf";
100
+ import { fromBinary, getOption, hasOption } from "@bufbuild/protobuf";
18
101
  import { reflect } from "@bufbuild/protobuf/reflect";
19
102
  import type { ReflectMessage } from "@bufbuild/protobuf/reflect";
20
103
 
104
+ import { AgentSchema } from "@stigmer/protos/ai/stigmer/agentic/agent/v1/api_pb";
105
+ import { ChannelAppSchema } from "@stigmer/protos/ai/stigmer/agentic/channelapp/v1/api_pb";
106
+ import { EnvironmentSchema } from "@stigmer/protos/ai/stigmer/agentic/environment/v1/api_pb";
21
107
  import { McpServerSchema } from "@stigmer/protos/ai/stigmer/agentic/mcpserver/v1/api_pb";
108
+ import { SkillSchema } from "@stigmer/protos/ai/stigmer/agentic/skill/v1/api_pb";
22
109
  import { ApiResourceKind } from "@stigmer/protos/ai/stigmer/commons/apiresource/apiresourcekind/api_resource_kind_pb";
110
+ import { ApiResourceVisibility } from "@stigmer/protos/ai/stigmer/commons/apiresource/enum_pb";
111
+ import { reference_kind } from "@stigmer/protos/ai/stigmer/commons/apiresource/field_options_pb";
112
+ import type { UpdateVisibilityInputSchema } from "@stigmer/protos/ai/stigmer/commons/apiresource/io_pb";
113
+ import { OAuthAppSchema } from "@stigmer/protos/ai/stigmer/iam/oauthapp/v1/api_pb";
23
114
 
24
115
  import type { Store } from "../../store/interface.js";
25
- import { failedPreconditionError, internalError } from "../errors.js";
116
+ import {
117
+ failedPreconditionError,
118
+ internalError,
119
+ invalidArgumentError,
120
+ } from "../errors.js";
26
121
  import type { PipelineStep } from "../pipeline.js";
27
122
  import type { RequestContext } from "../request-context.js";
28
- import { findResourceBySlug } from "./helpers.js";
29
123
  import { messageFieldByName, metadataOf } from "./shapes.js";
30
124
 
31
125
  const API_RESOURCE_REFERENCE_TYPE =
32
126
  "ai.stigmer.commons.apiresource.ApiResourceReference";
33
127
 
128
+ // ---------------------------------------------------------------------------
129
+ // The table.
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /** One kind a spec may reference, and how the rule reads it (the module header). */
133
+ export interface ReferenceTargetKind {
134
+ readonly kind: ApiResourceKind;
135
+ readonly schema: DescMessage;
136
+ /** Whether the run reads the target AS THE PERSON — the kinds the floor applies to. */
137
+ readonly readByRun: boolean;
138
+ /** The kind in the refusal copy's words, singular with the plural marker: "MCP server(s)". */
139
+ readonly label: string;
140
+ /** The CLI command a refusal points at, or undefined where the CLI has no verb for the kind. */
141
+ readonly listHint: string | undefined;
142
+ }
143
+
144
+ export const REFERENCE_TARGET_KINDS: ReadonlyArray<ReferenceTargetKind> = [
145
+ {
146
+ kind: ApiResourceKind.skill,
147
+ schema: SkillSchema,
148
+ readByRun: true,
149
+ label: "skill(s)",
150
+ listHint: "stigmer list skills",
151
+ },
152
+ {
153
+ kind: ApiResourceKind.mcp_server,
154
+ schema: McpServerSchema,
155
+ readByRun: true,
156
+ label: "MCP server(s)",
157
+ listHint: "stigmer get mcp-servers",
158
+ },
159
+ {
160
+ kind: ApiResourceKind.agent,
161
+ schema: AgentSchema,
162
+ readByRun: true,
163
+ label: "agent(s)",
164
+ listHint: "stigmer list agents",
165
+ },
166
+ {
167
+ kind: ApiResourceKind.environment,
168
+ schema: EnvironmentSchema,
169
+ readByRun: false,
170
+ label: "environment(s)",
171
+ listHint: "stigmer list environments",
172
+ },
173
+ {
174
+ kind: ApiResourceKind.channel_app,
175
+ schema: ChannelAppSchema,
176
+ readByRun: false,
177
+ label: "channel app(s)",
178
+ listHint: "stigmer list channel-app",
179
+ },
180
+ {
181
+ kind: ApiResourceKind.oauth_app,
182
+ schema: OAuthAppSchema,
183
+ readByRun: false,
184
+ label: "OAuth app(s)",
185
+ listHint: undefined,
186
+ },
187
+ ];
188
+
189
+ export function referenceTargetKind(
190
+ kind: ApiResourceKind,
191
+ ): ReferenceTargetKind | undefined {
192
+ return REFERENCE_TARGET_KINDS.find((entry) => entry.kind === kind);
193
+ }
194
+
195
+ // ---------------------------------------------------------------------------
196
+ // The rule.
197
+ // ---------------------------------------------------------------------------
198
+
199
+ /** One reference a spec carries, as the walker reads it; `kind` is the field's declared kind. */
200
+ export interface SpecReference {
201
+ readonly kind: ApiResourceKind;
202
+ readonly slug: string;
203
+ readonly org: string;
204
+ /**
205
+ * Present only on a RELATIVE reference (the module header): `org` is the
206
+ * resource's own, filled by the collector, and the runtime resolves the
207
+ * slug in the organization the resource runs in. The generic walker
208
+ * never sets it; only the `agent_call` collector does.
209
+ */
210
+ readonly resolvesIn?: "running-organization";
211
+ }
212
+
213
+ /** The resource the references belong to, as the rule needs it. */
214
+ export interface ReferenceParent {
215
+ readonly org: string;
216
+ readonly visibility: ApiResourceVisibility;
217
+ }
218
+
219
+ /** What the rule found for one reference; the collectors turn it into copy. */
220
+ export type ReferenceVerdict =
221
+ | { readonly kind: "ok" }
222
+ /** Clause (i): the reference names no organization. */
223
+ | { readonly kind: "no-org" }
224
+ /** Clause (ii): the same-organization target does not exist. */
225
+ | { readonly kind: "missing" }
226
+ /** Clause (ii): the target exists and is less visible than the resource. */
227
+ | {
228
+ readonly kind: "below-floor";
229
+ readonly targetVisibility: ApiResourceVisibility;
230
+ }
231
+ /** Clause (iii): the other-organization target is missing or not platform-visible — one answer. */
232
+ | { readonly kind: "not-available" };
233
+
234
+ /** The visibility of every referenced row the rule may need, one scan per kind (the module header). */
235
+ export interface ReferenceTargets {
236
+ visibilityOf(ref: SpecReference): ApiResourceVisibility | undefined;
237
+ }
238
+
239
+ /**
240
+ * Loads the rows the given references could name: one `listResources` per
241
+ * distinct kind, decoded once, indexed by (org, slug). A row that does not
242
+ * decode is skipped as the slug helpers skip it — it is not a row a
243
+ * reference can reach. A reference whose kind is not in the table is a
244
+ * contract the pin test has not admitted yet and is an internal fault.
245
+ */
246
+ export async function loadReferenceTargets(
247
+ store: Store,
248
+ refs: ReadonlyArray<SpecReference>,
249
+ ): Promise<ReferenceTargets> {
250
+ const byKind = new Map<ApiResourceKind, Map<string, ApiResourceVisibility>>();
251
+ for (const kind of new Set(refs.map((ref) => ref.kind))) {
252
+ const entry = referenceTargetKind(kind);
253
+ if (entry === undefined) {
254
+ throw internalError(
255
+ new Error(
256
+ `kind ${ApiResourceKind[kind] ?? kind} is not a reference target`,
257
+ ),
258
+ "reference rule table has no entry for the referenced kind",
259
+ );
260
+ }
261
+ const index = new Map<string, ApiResourceVisibility>();
262
+ for (const data of await store.listResources(kind)) {
263
+ let metadata;
264
+ try {
265
+ metadata = metadataOf(fromBinary(entry.schema, data));
266
+ } catch {
267
+ continue;
268
+ }
269
+ if (metadata === undefined || metadata.slug === "") {
270
+ continue;
271
+ }
272
+ index.set(targetKey(metadata.org, metadata.slug), metadata.visibility);
273
+ }
274
+ byKind.set(kind, index);
275
+ }
276
+ return {
277
+ visibilityOf(ref) {
278
+ return byKind.get(ref.kind)?.get(targetKey(ref.org, ref.slug));
279
+ },
280
+ };
281
+ }
282
+
283
+ function targetKey(org: string, slug: string): string {
284
+ return `${org}/${slug}`;
285
+ }
286
+
287
+ /** The three clauses of the module header, for one reference. */
288
+ export function checkReference(
289
+ targets: ReferenceTargets,
290
+ parent: ReferenceParent,
291
+ ref: SpecReference,
292
+ ): ReferenceVerdict {
293
+ if (ref.org === "") {
294
+ return { kind: "no-org" };
295
+ }
296
+ const target = targets.visibilityOf(ref);
297
+ if (ref.org !== parent.org) {
298
+ return target === ApiResourceVisibility.visibility_platform
299
+ ? { kind: "ok" }
300
+ : { kind: "not-available" };
301
+ }
302
+ if (target === undefined) {
303
+ return { kind: "missing" };
304
+ }
305
+ const entry = referenceTargetKind(ref.kind);
306
+ if (
307
+ entry?.readByRun === true &&
308
+ visibilityRank(target) < visibilityRank(floorOf(parent, ref))
309
+ ) {
310
+ return { kind: "below-floor", targetVisibility: target };
311
+ }
312
+ return { kind: "ok" };
313
+ }
314
+
315
+ /**
316
+ * The level a same-organization target must reach: the resource's own,
317
+ * capped at org for a relative reference (the module header).
318
+ */
319
+ function floorOf(
320
+ parent: ReferenceParent,
321
+ ref: SpecReference,
322
+ ): ApiResourceVisibility {
323
+ if (
324
+ ref.resolvesIn === "running-organization" &&
325
+ visibilityRank(parent.visibility) >
326
+ visibilityRank(ApiResourceVisibility.visibility_org)
327
+ ) {
328
+ return ApiResourceVisibility.visibility_org;
329
+ }
330
+ return parent.visibility;
331
+ }
332
+
333
+ /**
334
+ * The order the floor compares on: private < org < platform, an unset
335
+ * level reading as private (the level a row with no config holds). The
336
+ * retired public level is unreachable here — refused at every door and
337
+ * moved off every stored row by the store migration — so meeting it is a
338
+ * fault, not a rank.
339
+ */
340
+ function visibilityRank(level: ApiResourceVisibility): number {
341
+ switch (level) {
342
+ case ApiResourceVisibility.api_resource_visibility_unspecified:
343
+ case ApiResourceVisibility.visibility_private:
344
+ return 0;
345
+ case ApiResourceVisibility.visibility_org:
346
+ return 1;
347
+ case ApiResourceVisibility.visibility_platform:
348
+ return 2;
349
+ case ApiResourceVisibility.visibility_public:
350
+ throw new Error("the retired public level has no rank");
351
+ default: {
352
+ const exhaustive: never = level;
353
+ throw new Error(`unknown visibility level: ${String(exhaustive)}`);
354
+ }
355
+ }
356
+ }
357
+
358
+ // ---------------------------------------------------------------------------
359
+ // The copy.
360
+ // ---------------------------------------------------------------------------
361
+
362
+ /** Clause (i)'s sentence. */
363
+ export function noOrgReferenceMessage(
364
+ entry: ReferenceTargetKind,
365
+ slug: string,
366
+ ): string {
367
+ return `referenced ${entry.label} '${slug}' names no organization; a reference is 'org/slug', or 'slug' for a resource of this organization.`;
368
+ }
369
+
370
+ /**
371
+ * Clause (ii)'s sentence for the targets not found, one per kind. The MCP
372
+ * server form is the wire contract that predates the rule; the others are
373
+ * its siblings.
374
+ */
375
+ export function missingReferencesMessage(
376
+ entry: ReferenceTargetKind,
377
+ handles: ReadonlyArray<{ readonly slug: string; readonly org: string }>,
378
+ ): string {
379
+ const list = handles.map((h) => `'${h.slug}' (org: ${h.org})`).join(", ");
380
+ const hint =
381
+ entry.listHint === undefined
382
+ ? ""
383
+ : ` Use '${entry.listHint}' to list available ${plural(entry)}.`;
384
+ return `referenced ${entry.label} not found: ${list}. Verify the slug and org are correct.${hint}`;
385
+ }
386
+
387
+ /** Clause (ii)'s floor sentence: the dependency and its level, against the resource's. */
388
+ export function belowFloorMessage(
389
+ entry: ReferenceTargetKind,
390
+ ref: SpecReference,
391
+ targetVisibility: ApiResourceVisibility,
392
+ parentVisibility: ApiResourceVisibility,
393
+ ): string {
394
+ return `referenced ${singular(entry)} '${ref.org}/${ref.slug}' is ${levelWord(targetVisibility)} while this resource is ${levelWord(parentVisibility)}; a resource may not be more visible than the ${plural(entry)} it runs with. Widen the referenced resource's visibility or narrow this one.`;
395
+ }
396
+
397
+ /** The level as the copy names it: an unset level reads as private, which is how every reader treats it. */
398
+ function levelWord(level: ApiResourceVisibility): string {
399
+ return level === ApiResourceVisibility.api_resource_visibility_unspecified
400
+ ? ApiResourceVisibility[ApiResourceVisibility.visibility_private]
401
+ : ApiResourceVisibility[level];
402
+ }
403
+
404
+ /** Clause (iii)'s one sentence — the same whether the target is missing or not platform-visible. */
405
+ export function notAvailableReferenceMessage(
406
+ entry: ReferenceTargetKind,
407
+ ref: SpecReference,
408
+ ): string {
409
+ return `referenced ${singular(entry)} '${ref.org}/${ref.slug}' is not available to this organization; another organization's resource can be referenced only when that organization shares it at platform visibility.`;
410
+ }
411
+
412
+ /** "MCP server(s)" → "MCP server" and "MCP servers": the label's two readings. */
413
+ function singular(entry: ReferenceTargetKind): string {
414
+ return entry.label.replace("(s)", "");
415
+ }
416
+
417
+ function plural(entry: ReferenceTargetKind): string {
418
+ return entry.label.replace("(s)", "s");
419
+ }
420
+
421
+ /**
422
+ * The refusal for a set of verdicts, or undefined when every one is ok.
423
+ * Missing same-organization targets are grouped per kind into one sentence
424
+ * (the contract's shape); every other refusal is its own sentence; the
425
+ * sentences are joined in the order the references were read. A no-org
426
+ * reference is malformed input (INVALID_ARGUMENT); everything else is a
427
+ * precondition the store does not meet (FAILED_PRECONDITION), the code the
428
+ * MCP-server contract already answers.
429
+ */
430
+ export function referenceRefusal(
431
+ parent: ReferenceParent,
432
+ verdicts: ReadonlyArray<{
433
+ readonly ref: SpecReference;
434
+ readonly verdict: ReferenceVerdict;
435
+ }>,
436
+ ): ReturnType<typeof failedPreconditionError> | undefined {
437
+ // Missing same-organization targets, grouped per kind in the order the
438
+ // first of each kind was read.
439
+ const missingByKind = new Map<ApiResourceKind, SpecReference[]>();
440
+ for (const { ref, verdict } of verdicts) {
441
+ if (verdict.kind === "missing") {
442
+ const held = missingByKind.get(ref.kind);
443
+ if (held === undefined) {
444
+ missingByKind.set(ref.kind, [ref]);
445
+ } else {
446
+ held.push(ref);
447
+ }
448
+ }
449
+ }
450
+ const sentences: string[] = [];
451
+ const groupedKindsSaid = new Set<ApiResourceKind>();
452
+ let malformed = false;
453
+ for (const { ref, verdict } of verdicts) {
454
+ const entry = referenceTargetKind(ref.kind);
455
+ if (entry === undefined) {
456
+ continue;
457
+ }
458
+ switch (verdict.kind) {
459
+ case "ok":
460
+ break;
461
+ case "no-org":
462
+ malformed = true;
463
+ sentences.push(noOrgReferenceMessage(entry, ref.slug));
464
+ break;
465
+ case "missing":
466
+ if (!groupedKindsSaid.has(ref.kind)) {
467
+ groupedKindsSaid.add(ref.kind);
468
+ sentences.push(
469
+ missingReferencesMessage(entry, missingByKind.get(ref.kind) ?? []),
470
+ );
471
+ }
472
+ break;
473
+ case "below-floor":
474
+ sentences.push(
475
+ belowFloorMessage(
476
+ entry,
477
+ ref,
478
+ verdict.targetVisibility,
479
+ parent.visibility,
480
+ ),
481
+ );
482
+ break;
483
+ case "not-available":
484
+ sentences.push(notAvailableReferenceMessage(entry, ref));
485
+ break;
486
+ default: {
487
+ const exhaustive: never = verdict;
488
+ throw new Error(`unknown verdict: ${JSON.stringify(exhaustive)}`);
489
+ }
490
+ }
491
+ }
492
+ if (sentences.length === 0) {
493
+ return undefined;
494
+ }
495
+ const text = sentences.join(" ");
496
+ return malformed ? invalidArgumentError(text) : failedPreconditionError(text);
497
+ }
498
+
499
+ // ---------------------------------------------------------------------------
500
+ // The steps.
501
+ // ---------------------------------------------------------------------------
502
+
34
503
  export function newNormalizeReferencesStep<
35
504
  Desc extends DescMessage,
36
505
  >(): PipelineStep<Desc> {
@@ -44,8 +513,8 @@ export function newNormalizeReferencesStep<
44
513
  "normalize references",
45
514
  );
46
515
  }
47
- // No org to resolve from — skip silently; required-org validation is
48
- // the validation step's responsibility, not this one's.
516
+ // No org to resolve from — skip silently; the rule's first clause
517
+ // refuses a reference left without one.
49
518
  if (metadata.org === "") {
50
519
  return;
51
520
  }
@@ -65,68 +534,131 @@ export function newValidateReferencesStep<Desc extends DescMessage>(
65
534
  return {
66
535
  name: "ValidateReferences",
67
536
  async execute(ctx: RequestContext<Desc>): Promise<void> {
68
- const refs: Array<{ kind: number; slug: string; org: string }> = [];
69
- forEachSpecReference(ctx.schema, ctx.newState, (ref) => {
70
- refs.push({
71
- kind: numberField(ref, "kind"),
72
- slug: stringField(ref, "slug"),
73
- org: stringField(ref, "org"),
74
- });
75
- });
76
-
77
- const missingMcpServers: string[] = [];
78
- for (const ref of refs) {
79
- if (ref.slug === "") {
80
- continue;
81
- }
82
- // Per-kind validation, exactly Go's switch: only mcp_server today.
83
- if (ref.kind === ApiResourceKind.mcp_server) {
84
- const found = await findResourceBySlug(
85
- store,
86
- ApiResourceKind.mcp_server,
87
- McpServerSchema,
88
- ref.slug,
89
- ref.org,
90
- );
91
- if (found === undefined) {
92
- missingMcpServers.push(`'${ref.slug}' (org: ${ref.org})`);
93
- }
94
- }
537
+ const metadata = metadataOf(ctx.newState);
538
+ if (metadata === undefined) {
539
+ throw internalError(
540
+ new Error("resource metadata is nil"),
541
+ "validate references",
542
+ );
543
+ }
544
+ const refs = collectSpecReferences(ctx.schema, ctx.newState).filter(
545
+ (ref) => ref.slug !== "",
546
+ );
547
+ if (refs.length === 0) {
548
+ return;
95
549
  }
550
+ const parent: ReferenceParent = {
551
+ org: metadata.org,
552
+ visibility: metadata.visibility,
553
+ };
554
+ const refusal = await checkReferences(store, parent, refs);
555
+ if (refusal !== undefined) {
556
+ throw refusal;
557
+ }
558
+ },
559
+ };
560
+ }
96
561
 
97
- if (missingMcpServers.length > 0) {
98
- throw failedPreconditionError(
99
- `referenced MCP server(s) not found: ${missingMcpServers.join(", ")}. ` +
100
- "Verify the slug and org are correct. " +
101
- "Use 'stigmer get mcp-servers' to list available MCP servers.",
562
+ /** The rule over a collected list: load once, check each, render the refusal. */
563
+ export async function checkReferences(
564
+ store: Store,
565
+ parent: ReferenceParent,
566
+ refs: ReadonlyArray<SpecReference>,
567
+ ): Promise<ReturnType<typeof referenceRefusal>> {
568
+ const targets = await loadReferenceTargets(store, refs);
569
+ return referenceRefusal(
570
+ parent,
571
+ refs.map((ref) => ({ ref, verdict: checkReference(targets, parent, ref) })),
572
+ );
573
+ }
574
+
575
+ /** The references a stored row carries, for the escalation door; a chain passes one collector per place its row keeps them. */
576
+ export type ReferenceCollector = (row: Message) => ReadonlyArray<SpecReference>;
577
+
578
+ /**
579
+ * The floor's second door (the module header): on an updateVisibility chain,
580
+ * after the loaded row is on the context under `targetKey` and after
581
+ * ValidateVisibilityUpdate, refuses raising the level while a run-read
582
+ * reference the row carries would end below it.
583
+ */
584
+ export function newGuardReferenceFloorOnEscalationStep(
585
+ store: Store,
586
+ targetKey: string,
587
+ collectors: ReadonlyArray<ReferenceCollector>,
588
+ ): PipelineStep<typeof UpdateVisibilityInputSchema> {
589
+ return {
590
+ name: "GuardReferenceFloorOnEscalation",
591
+ async execute(
592
+ ctx: RequestContext<typeof UpdateVisibilityInputSchema>,
593
+ ): Promise<void> {
594
+ const row = ctx.get(targetKey) as Message | undefined;
595
+ if (row === undefined) {
596
+ // Wiring error, not a user error: a guard that silently passes
597
+ // un-guards the boundary it exists to protect.
598
+ throw internalError(
599
+ new Error(
600
+ "GuardReferenceFloorOnEscalation ran without a loaded target — the step must follow the load step",
601
+ ),
602
+ "reference floor requires the loaded resource",
603
+ );
604
+ }
605
+ const metadata = metadataOf(row);
606
+ if (metadata === undefined) {
607
+ throw internalError(
608
+ new Error("resource metadata is nil"),
609
+ "reference floor requires the loaded resource",
102
610
  );
103
611
  }
612
+ const requested = ctx.input.visibility;
613
+ if (visibilityRank(requested) <= visibilityRank(metadata.visibility)) {
614
+ return;
615
+ }
616
+ const refs = collectors
617
+ .flatMap((collect) => collect(row))
618
+ .filter((ref) => ref.slug !== "");
619
+ if (refs.length === 0) {
620
+ return;
621
+ }
622
+ const parent: ReferenceParent = {
623
+ org: metadata.org,
624
+ visibility: requested,
625
+ };
626
+ const targets = await loadReferenceTargets(store, refs);
627
+ const refusal = referenceRefusal(
628
+ parent,
629
+ refs
630
+ .map((ref) => ({
631
+ ref,
632
+ verdict: checkReference(targets, parent, ref),
633
+ }))
634
+ .filter(({ verdict }) => verdict.kind === "below-floor"),
635
+ );
636
+ if (refusal !== undefined) {
637
+ throw refusal;
638
+ }
104
639
  },
105
640
  };
106
641
  }
107
642
 
108
- /** One reference a spec carries, as the walker reads it. */
109
- export interface SpecReference {
110
- readonly kind: ApiResourceKind;
111
- readonly slug: string;
112
- readonly org: string;
113
- }
643
+ // ---------------------------------------------------------------------------
644
+ // The walk.
645
+ // ---------------------------------------------------------------------------
114
646
 
115
647
  /**
116
- * Every ApiResourceReference in a resource's spec — the same walk
117
- * ValidateReferences runs, exported for the readers that ask the reverse
118
- * question ("who references this?"): the plugin delete guard scans an
119
- * organization's agents and workflows for references to a member it is
120
- * about to remove.
648
+ * Every ApiResourceReference in a resource's spec, each with the kind its
649
+ * field declares — the walk both steps run, exported for the readers that
650
+ * ask the reverse question ("who references this?"): the plugin delete
651
+ * guard scans an organization's agents and workflows for references to a
652
+ * member it is about to remove.
121
653
  */
122
654
  export function collectSpecReferences(
123
655
  schema: DescMessage,
124
656
  msg: Parameters<typeof reflect>[1],
125
657
  ): SpecReference[] {
126
658
  const refs: SpecReference[] = [];
127
- forEachSpecReference(schema, msg, (ref) => {
659
+ forEachSpecReference(schema, msg, (ref, field) => {
128
660
  refs.push({
129
- kind: numberField(ref, "kind") as ApiResourceKind,
661
+ kind: declaredKind(field, ref),
130
662
  slug: stringField(ref, "slug"),
131
663
  org: stringField(ref, "org"),
132
664
  });
@@ -134,16 +666,29 @@ export function collectSpecReferences(
134
666
  return refs;
135
667
  }
136
668
 
669
+ /**
670
+ * The kind a reference names: the field's `reference_kind` option when the
671
+ * contract declares one (every reference field does), else the kind the
672
+ * message carries (the walk's only fallback, for a field the contract has
673
+ * not annotated).
674
+ */
675
+ function declaredKind(field: DescField, ref: ReflectMessage): ApiResourceKind {
676
+ if (hasOption(field, reference_kind)) {
677
+ return getOption(field, reference_kind);
678
+ }
679
+ return numberField(ref, "kind") as ApiResourceKind;
680
+ }
681
+
137
682
  /**
138
683
  * Walks the resource's spec and invokes fn on every ApiResourceReference
139
- * (singular, repeated, and map-valued message fields, recursively) —
140
- * the shared traversal behind both steps (Go walkAndResolveOrg /
141
- * walkAndCollectRefs). Mutations through the ReflectMessage write through.
684
+ * (singular, repeated, and map-valued message fields, recursively) with
685
+ * the field that holds it. Mutations through the ReflectMessage write
686
+ * through.
142
687
  */
143
688
  function forEachSpecReference(
144
689
  schema: DescMessage,
145
690
  msg: Parameters<typeof reflect>[1],
146
- fn: (ref: ReflectMessage) => void,
691
+ fn: (ref: ReflectMessage, field: DescField) => void,
147
692
  ): void {
148
693
  const root = reflect(schema, msg);
149
694
  const specField = messageFieldByName(root, "spec");
@@ -153,14 +698,17 @@ function forEachSpecReference(
153
698
  walk(root.get(specField), fn);
154
699
  }
155
700
 
156
- function walk(msg: ReflectMessage, fn: (ref: ReflectMessage) => void): void {
701
+ function walk(
702
+ msg: ReflectMessage,
703
+ fn: (ref: ReflectMessage, field: DescField) => void,
704
+ ): void {
157
705
  for (const field of msg.fields) {
158
706
  if (field.fieldKind === "list") {
159
707
  if (field.listKind !== "message") {
160
708
  continue;
161
709
  }
162
710
  for (const item of msg.get(field)) {
163
- visit(item as ReflectMessage, fn);
711
+ visit(item as ReflectMessage, field, fn);
164
712
  }
165
713
  } else if (field.fieldKind === "map") {
166
714
  if (field.mapKind !== "message") {
@@ -168,20 +716,24 @@ function walk(msg: ReflectMessage, fn: (ref: ReflectMessage) => void): void {
168
716
  }
169
717
  const map = msg.get(field);
170
718
  for (const [, value] of map) {
171
- visit(value as ReflectMessage, fn);
719
+ visit(value as ReflectMessage, field, fn);
172
720
  }
173
721
  } else if (field.fieldKind === "message") {
174
722
  if (!msg.isSet(field)) {
175
723
  continue;
176
724
  }
177
- visit(msg.get(field), fn);
725
+ visit(msg.get(field), field, fn);
178
726
  }
179
727
  }
180
728
  }
181
729
 
182
- function visit(sub: ReflectMessage, fn: (ref: ReflectMessage) => void): void {
730
+ function visit(
731
+ sub: ReflectMessage,
732
+ field: DescField,
733
+ fn: (ref: ReflectMessage, field: DescField) => void,
734
+ ): void {
183
735
  if (sub.desc.typeName === API_RESOURCE_REFERENCE_TYPE) {
184
- fn(sub);
736
+ fn(sub, field);
185
737
  } else {
186
738
  walk(sub, fn);
187
739
  }